Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b28ad88b22 | ||
|
|
b871d62de6 | ||
|
|
4d83b8d673 | ||
|
|
2820cff988 | ||
|
|
54b7ea3bd5 | ||
|
|
57bcfcd9e1 | ||
|
|
1317a73e9a | ||
|
|
039d2f3dd9 | ||
|
|
54a0b5b966 | ||
|
|
1639ba40f3 | ||
|
|
26a96bc824 | ||
|
|
4fc5c96e63 |
@@ -0,0 +1,28 @@
|
||||
# EvoBGP web (Vite) — переменные окружения.
|
||||
# Скопируйте в apps/web/.env.local (файл в .gitignore) и заполните.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ReUI Pro/Ultimate — ключ с https://reui.io/account (для `pnpm dlx shadcn add @reui/*`)
|
||||
# ---------------------------------------------------------------------------
|
||||
# REUI_LICENSE_KEY=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App Switcher — JSON с описанием шапки «Приложения» (fallback, когда portal
|
||||
# недоступен либо VITE_AUTH_ENABLED=false).
|
||||
# Схема: см. apps/web/src/lib/app-switcher-config.ts.
|
||||
# ---------------------------------------------------------------------------
|
||||
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[...]}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-portal SSO
|
||||
# ---------------------------------------------------------------------------
|
||||
# Включает JWT-гейт через auth-portal вместо локального evobgp_api_token.
|
||||
# Пример:
|
||||
# VITE_AUTH_ENABLED=true
|
||||
# VITE_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
#
|
||||
# Backend опционально может отдавать GET /v1/auth/config
|
||||
# ({ "required": true, "portal_url": "https://auth.shnt.top" }) —
|
||||
# ответ имеет приоритет над VITE_* и позволяет менять режим без пересборки.
|
||||
# VITE_AUTH_ENABLED=false
|
||||
# VITE_AUTH_PORTAL_URL=http://localhost:5175
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
|
||||
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
getCurrentApp,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
|
||||
/** Sidebar app switcher — shared chrome etalon EvoBGP. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function AppSwitcher() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
const current = getCurrentApp(config)
|
||||
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<CurrentIcon className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{current.name}</span>
|
||||
{current.subtitle ? (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{current.subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||
</div>
|
||||
{config.apps.map((app) => {
|
||||
const Icon = APP_SWITCHER_ICONS[app.icon]
|
||||
const isCurrent = app.id === CURRENT_APP_ID
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<DropdownMenuItem key={app.id} disabled>
|
||||
<Icon />
|
||||
{app.name}
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
nativeButton={false}
|
||||
render={<a href={app.url} />}
|
||||
>
|
||||
<Icon />
|
||||
{app.name}
|
||||
{app.shortcut ? (
|
||||
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -122,6 +122,7 @@ function buildKpis({
|
||||
iconClassName: 'text-destructive',
|
||||
value: loading ? '—' : String(riskCount),
|
||||
label: 'Риски',
|
||||
variant: riskCount > 0 ? 'destructive' : 'default',
|
||||
footer: (
|
||||
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||
{loading
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
|
||||
export type QuickLinkCardProps = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
description: string
|
||||
to: string
|
||||
search?: Record<string, string>
|
||||
iconClass: string
|
||||
}
|
||||
|
||||
export function DashboardQuickLinkCard({
|
||||
icon,
|
||||
label,
|
||||
description,
|
||||
to,
|
||||
search,
|
||||
iconClass,
|
||||
}: QuickLinkCardProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
search={search}
|
||||
className="group block h-full rounded-[inherit] focus-visible:outline-none"
|
||||
aria-label={`${label}: ${description}`}
|
||||
>
|
||||
<Card
|
||||
size="sm"
|
||||
className={cn(
|
||||
'relative isolate h-full overflow-hidden transition-colors',
|
||||
'hover:border-foreground/20',
|
||||
'group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background',
|
||||
)}
|
||||
>
|
||||
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
|
||||
<CardContent className="relative z-10 flex h-full flex-col gap-7.5 p-5">
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background flex size-11 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5',
|
||||
iconClass,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="mt-auto flex flex-col gap-3">
|
||||
<span className="text-foreground block text-sm leading-tight font-medium">{label}</span>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
|
||||
<span className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 group-hover:underline">
|
||||
Перейти
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="size-2.5 shrink-0 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +1,76 @@
|
||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Gauge, Network, Play, Plus, Search, Share2, Tags } from 'lucide-react'
|
||||
|
||||
import { DashboardQuickLinkCard } from '@/components/dashboard/dashboard-quick-link-card'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { QuickActionGrid, type QuickActionItem } from '@/components/reui-kit'
|
||||
|
||||
type QuickLink = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
description: string
|
||||
to: string
|
||||
search?: Record<string, string>
|
||||
iconClass: string
|
||||
}
|
||||
|
||||
const LINKS: QuickLink[] = [
|
||||
/** iconClassName: semantic text only (shared chrome with CFDM). @see https://reui.io/preview/base/stats-12 */
|
||||
const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
icon: <Plus aria-hidden />,
|
||||
label: 'Создать модуль',
|
||||
id: 'lookup',
|
||||
title: 'Проверка IP/домена',
|
||||
description: 'Membership в списках и community (entry + snapshot).',
|
||||
to: '/lookup',
|
||||
icon: <Search aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
},
|
||||
{
|
||||
id: 'new-module',
|
||||
title: 'Создать модуль',
|
||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||
to: '/modules/new',
|
||||
iconClass: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
|
||||
icon: <Plus aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
},
|
||||
{
|
||||
icon: <Tags aria-hidden />,
|
||||
label: 'BGP-сообщества',
|
||||
id: 'communities',
|
||||
title: 'BGP-сообщества',
|
||||
description: 'Справочник communities для политик экспорта.',
|
||||
to: '/directories',
|
||||
iconClass: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
{
|
||||
icon: <Network aria-hidden />,
|
||||
label: 'Сеть',
|
||||
id: 'network',
|
||||
title: 'Сеть',
|
||||
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
||||
to: '/network',
|
||||
search: { tab: 'overview' },
|
||||
iconClass: 'bg-success text-success-foreground [&_svg]:text-success-foreground',
|
||||
icon: <Network aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
},
|
||||
{
|
||||
icon: <Share2 aria-hidden />,
|
||||
label: 'Добавить пира',
|
||||
id: 'add-peer',
|
||||
title: 'Добавить пира',
|
||||
description: 'Настройка BGP-соседа и шаблонов сессии.',
|
||||
to: '/network',
|
||||
search: { tab: 'peers' },
|
||||
iconClass: 'bg-warning text-warning-foreground [&_svg]:text-warning-foreground',
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
},
|
||||
{
|
||||
icon: <Play aria-hidden />,
|
||||
label: 'Деплой',
|
||||
id: 'deploy',
|
||||
title: 'Деплой',
|
||||
description: 'Ревизии конфигурации и применение на нодах.',
|
||||
to: '/operations',
|
||||
search: { tab: 'revisions' },
|
||||
iconClass: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
|
||||
icon: <Play aria-hidden />,
|
||||
iconClassName: 'text-muted-foreground',
|
||||
},
|
||||
{
|
||||
icon: <Gauge aria-hidden />,
|
||||
label: 'Мониторинг',
|
||||
id: 'monitoring',
|
||||
title: 'Мониторинг',
|
||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||
to: '/monitoring',
|
||||
search: { tab: 'system' },
|
||||
iconClass: 'bg-destructive text-destructive-foreground [&_svg]:text-destructive-foreground',
|
||||
icon: <Gauge aria-hidden />,
|
||||
iconClassName: 'text-destructive',
|
||||
},
|
||||
]
|
||||
|
||||
export function DashboardQuickLinks() {
|
||||
return (
|
||||
<PanelCard
|
||||
title="Быстрые действия"
|
||||
<QuickActionGrid
|
||||
actions={ACTIONS}
|
||||
description="Частые переходы к настройке и деплою"
|
||||
className="@container w-full"
|
||||
contentClassName="grid gap-3 p-4 sm:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
{LINKS.map((link) => (
|
||||
<DashboardQuickLinkCard key={link.label} {...link} />
|
||||
))}
|
||||
</PanelCard>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ export {
|
||||
kpiStatItemKey,
|
||||
type KpiStatItem,
|
||||
type KpiStatCardData,
|
||||
type KpiStatVariant,
|
||||
type OpsKpiCard,
|
||||
} from '@/components/reui-kit/kpi-stat-grid'
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
KeyRound,
|
||||
ServerCog,
|
||||
Shield,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -41,9 +42,13 @@ import { TooltipProvider } from '@evobgp/ui/components/tooltip'
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import type { ComponentType, CSSProperties, ReactNode } from 'react'
|
||||
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
|
||||
import { NavUser } from '@/components/layout/nav-user'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
@@ -51,6 +56,8 @@ interface NavItem {
|
||||
icon: ComponentType<{ className?: string }>
|
||||
description?: string
|
||||
search?: Record<string, string>
|
||||
/** Explicit permission override; when omitted derived from `to`. */
|
||||
permission?: string
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
@@ -74,6 +81,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
label: 'Маршрутизация',
|
||||
items: [
|
||||
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
||||
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
||||
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
||||
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
|
||||
],
|
||||
@@ -99,6 +107,18 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
|
||||
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
|
||||
|
||||
/** Nav visible for the current claim (or all items when portal auth is off). */
|
||||
function useVisibleNavGroups(): NavGroup[] {
|
||||
if (!isAuthEnabled()) return NAV_GROUPS
|
||||
return NAV_GROUPS.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => {
|
||||
const perm = item.permission ?? permissionForPath(item.to)
|
||||
return !perm || can(perm)
|
||||
}),
|
||||
})).filter((group) => group.items.length > 0)
|
||||
}
|
||||
|
||||
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||
)
|
||||
@@ -116,8 +136,14 @@ const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
|
||||
keywords: [item.to.replace(/^\//, '')],
|
||||
}))
|
||||
|
||||
/**
|
||||
* Shared ops chrome etalon for CFDM / vps-tracker.
|
||||
* @see https://reui.io/preview/base/app-shell-12
|
||||
* @see docs/ui-design-contract.md — Shared App Shell chrome
|
||||
*/
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const visibleGroups = useVisibleNavGroups()
|
||||
const activeItem =
|
||||
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
||||
ALL_NAV_ITEMS[0]
|
||||
@@ -134,29 +160,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '260px',
|
||||
'--sidebar-width-icon': '62px',
|
||||
'--header-height': '56px',
|
||||
'--sidebar-width': '240px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="gap-2">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
|
||||
B
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-sm font-semibold">EvoBGP</span>
|
||||
<span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
||||
<CommandPalette items={COMMAND_ITEMS} />
|
||||
</div>
|
||||
<SidebarHeader>
|
||||
<AppSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
{visibleGroups.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
@@ -182,11 +195,13 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter />
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="sticky top-0 z-10 flex h-(--header-height) shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<SidebarTrigger />
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
@@ -204,12 +219,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<AppsMenu />
|
||||
<SystemMonitorPopover />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
||||
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { LayoutGridIcon } from 'lucide-react'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
|
||||
|
||||
/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function AppsMenu() {
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" aria-label="Приложения" />
|
||||
}
|
||||
>
|
||||
<LayoutGridIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-72"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||
</DropdownMenuLabel>
|
||||
<div className="grid grid-cols-3 gap-1 p-1">
|
||||
{config.apps.map((app) => {
|
||||
const Icon = APP_SWITCHER_ICONS[app.icon]
|
||||
const isCurrent = app.id === CURRENT_APP_ID
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
disabled
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
nativeButton={false}
|
||||
render={<a href={app.url} />}
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{isAuthEnabled() ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={authPortalUrl()} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настроить на портале
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -29,9 +29,11 @@ export type CommandPaletteItem = {
|
||||
interface CommandPaletteProps {
|
||||
items: CommandPaletteItem[]
|
||||
className?: string
|
||||
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
|
||||
hotkeyOnly?: boolean
|
||||
}
|
||||
|
||||
export function CommandPalette({ items }: CommandPaletteProps) {
|
||||
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputId = useId()
|
||||
@@ -68,25 +70,27 @@ export function CommandPalette({ items }: CommandPaletteProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarGroupContent className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Поиск…
|
||||
</Button>
|
||||
<Search
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
|
||||
/>
|
||||
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
|
||||
⌘K
|
||||
</Kbd>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
{!hotkeyOnly ? (
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarGroupContent className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Поиск…
|
||||
</Button>
|
||||
<Search
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
|
||||
/>
|
||||
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
|
||||
⌘K
|
||||
</Kbd>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
ChevronsUpDownIcon,
|
||||
ExternalLinkIcon,
|
||||
LogOutIcon,
|
||||
SettingsIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
|
||||
import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||
import {
|
||||
authPortalUrl,
|
||||
clearPortalToken,
|
||||
getClaims,
|
||||
isAuthEnabled,
|
||||
redirectToPortalLogout,
|
||||
resetPortalHandoff,
|
||||
} from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* Sidebar footer account menu.
|
||||
* Portal mode → shows JWT email + logout via auth-portal.
|
||||
* Local mode → shows the API-key hint + clears the local token.
|
||||
* @see https://reui.io/preview/base/app-shell-12
|
||||
*/
|
||||
function initials(source: string): string {
|
||||
const base = source.trim()
|
||||
if (!base) return '?'
|
||||
const parts = base.split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
||||
}
|
||||
return base.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function NavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const authOn = isAuthEnabled()
|
||||
const claims = getClaims()
|
||||
|
||||
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
|
||||
const email =
|
||||
claims?.email?.trim() ||
|
||||
(authOn ? '' : 'локальный API-токен')
|
||||
const fallback = initials(name || email)
|
||||
|
||||
function handleSignOut() {
|
||||
if (authOn) {
|
||||
clearPortalToken()
|
||||
resetPortalHandoff()
|
||||
redirectToPortalLogout()
|
||||
return
|
||||
}
|
||||
setApiToken(null)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||
window.location.assign('/settings?tab=connection&reason=token-required')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">
|
||||
{fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--anchor-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">
|
||||
{fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||
>
|
||||
<SettingsIcon aria-hidden />
|
||||
Настройки UI
|
||||
</DropdownMenuItem>
|
||||
{authOn ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={authPortalUrl()} />}
|
||||
>
|
||||
<ExternalLinkIcon aria-hidden />
|
||||
Открыть Auth Portal
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOutIcon aria-hidden />
|
||||
{authOn ? 'Выйти' : 'Сбросить токен'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export function SystemMonitorPopover() {
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="w-80 gap-0! space-y-0! p-0!">
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridCard, DataGridSection } from '@/components/data-grid-shell'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { LookupMatch } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Lookup matches grid — data-grid-filtering-2 pattern.
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function LookupMatchesGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: LookupMatch[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const columns = useMemo<ColumnDef<LookupMatch>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'layer',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Слой" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.layer === 'entry' ? 'info-light' : 'primary-light'}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.layer}
|
||||
</Badge>
|
||||
),
|
||||
meta: { headerTitle: 'Слой' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'module_name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.module_name}
|
||||
subtitle={row.original.module_type}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'matched_value',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.matched_value}
|
||||
subtitle={
|
||||
row.original.resolved_ip
|
||||
? `${row.original.match_kind} · via ${row.original.resolved_ip}`
|
||||
: row.original.match_kind
|
||||
}
|
||||
accent="mono"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Совпадение' },
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
accessorFn: (row) => row.community_title || row.community || '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Community" />,
|
||||
cell: ({ row }) => {
|
||||
const title = row.original.community_title?.trim()
|
||||
const value = row.original.community?.trim()
|
||||
if (!title && !value) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>
|
||||
}
|
||||
return (
|
||||
<DataGridPrimaryCell
|
||||
title={title || value || '—'}
|
||||
subtitle={title && value && title !== value ? value : undefined}
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Community' },
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
enableSorting: false,
|
||||
header: 'Источник',
|
||||
cell: ({ row }) =>
|
||||
row.original.source ? (
|
||||
<CategoryBadge>{row.original.source}</CategoryBadge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
),
|
||||
meta: { headerTitle: 'Источник' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.layer} ${row.module_name} ${row.module_type} ${row.matched_value} ${row.community ?? ''} ${row.community_title ?? ''} ${row.source ?? ''}`,
|
||||
getRowId: (row) =>
|
||||
`${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridCard
|
||||
title="Совпадения"
|
||||
description="Entries и snapshots · клик по строке открывает модуль"
|
||||
>
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет совпадений"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Фильтр совпадений…"
|
||||
onRowClick={(row) =>
|
||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
||||
}
|
||||
/>
|
||||
</DataGridCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
/**
|
||||
* Lookup search form — Frame + InputGroup (form-7 pattern).
|
||||
* @see https://reui.io/preview/base/form-7
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function LookupSearchForm({
|
||||
initialQuery = '',
|
||||
isPending = false,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialQuery?: string
|
||||
isPending?: boolean
|
||||
onSubmit: (q: string) => void
|
||||
}) {
|
||||
const [value, setValue] = useState(initialQuery)
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const q = value.trim()
|
||||
if (!q) return
|
||||
onSubmit(q)
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Проверка списка</FrameTitle>
|
||||
<FrameDescription>
|
||||
IP или FQDN — поиск в entries и материализованных snapshots с community.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<Field className="min-w-0 flex-1">
|
||||
<FieldLabel htmlFor="lookup-q">IP или домен</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Search aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="lookup-q"
|
||||
name="q"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="8.8.8.8 или example.com"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Button type="submit" disabled={isPending || !value.trim()}>
|
||||
Проверить
|
||||
</Button>
|
||||
</form>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Globe, Layers, ListChecks, Radar } from 'lucide-react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import type { LookupResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Lookup summary KPI — stats-12 via KpiStatGrid.
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function LookupSummaryKpi({ data }: { data: LookupResponse }) {
|
||||
const entryCount = data.matches.filter((m) => m.layer === 'entry').length
|
||||
const snapshotCount = data.matches.filter((m) => m.layer === 'snapshot').length
|
||||
const resolvedCount = data.resolved_ips?.length ?? 0
|
||||
|
||||
const items: KpiStatItem[] = [
|
||||
{
|
||||
id: 'matched',
|
||||
label: 'Результат',
|
||||
value: data.matched ? 'Найдено' : 'Не найдено',
|
||||
hint: data.normalized,
|
||||
icon: <Radar aria-hidden />,
|
||||
iconClassName: data.matched
|
||||
? 'bg-success text-success-foreground [&_svg]:text-success-foreground'
|
||||
: 'bg-muted text-muted-foreground [&_svg]:text-muted-foreground',
|
||||
variant: data.matched ? 'default' : 'warning',
|
||||
},
|
||||
{
|
||||
id: 'entry',
|
||||
label: 'Слой entry',
|
||||
value: entryCount,
|
||||
hint: 'сырые списки',
|
||||
icon: <ListChecks aria-hidden />,
|
||||
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
|
||||
},
|
||||
{
|
||||
id: 'snapshot',
|
||||
label: 'Слой snapshot',
|
||||
value: snapshotCount,
|
||||
hint: 'материализация',
|
||||
icon: <Layers aria-hidden />,
|
||||
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
|
||||
},
|
||||
]
|
||||
|
||||
if (data.query_kind === 'domain') {
|
||||
items.push({
|
||||
id: 'resolved',
|
||||
label: 'DNS IP',
|
||||
value: resolvedCount,
|
||||
hint: resolvedCount > 0 ? data.resolved_ips?.slice(0, 3).join(', ') : 'нет A/AAAA',
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<KpiStatGrid
|
||||
items={items}
|
||||
aria-label={`Запрос: ${data.query_kind} · ${data.query}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { AlertTriangle, Network, ServerCog, Share2 } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { KpiStatGrid, type KpiStatCardData } from '@/components/reui-kit'
|
||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||
import type { BirdStatus, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Network page KPI strip.
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function NetworkKpi({
|
||||
peers,
|
||||
speakers,
|
||||
bird,
|
||||
loading,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
bird?: BirdStatus
|
||||
loading?: boolean
|
||||
}) {
|
||||
const net = aggregateNetworkMetrics(peers, speakers)
|
||||
const birdHealthy = bird?.healthy
|
||||
const birdSessions =
|
||||
bird != null ? `${bird.bgp_established} / ${bird.bgp_sessions_total}` : '—'
|
||||
|
||||
const cards: KpiStatCardData[] = [
|
||||
{
|
||||
id: 'peers-established',
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
label: 'Пиры Established',
|
||||
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{loading ? '…' : `${net.peersTotal} в каталоге`}
|
||||
</Badge>
|
||||
),
|
||||
to: '/network',
|
||||
search: { tab: 'peers' },
|
||||
},
|
||||
{
|
||||
id: 'speakers-online',
|
||||
icon: <ServerCog aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Спикеры online',
|
||||
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
footer: (
|
||||
<Badge
|
||||
variant={
|
||||
net.speakersOnline === net.speakersTotal && net.speakersTotal > 0
|
||||
? 'success-light'
|
||||
: 'warning-light'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'live'}
|
||||
</Badge>
|
||||
),
|
||||
to: '/network',
|
||||
search: { tab: 'speakers' },
|
||||
},
|
||||
{
|
||||
id: 'mismatches',
|
||||
icon: <AlertTriangle aria-hidden />,
|
||||
iconClassName: net.peersMismatch > 0 ? 'text-warning' : 'text-muted-foreground',
|
||||
label: 'Расхождения',
|
||||
value: loading ? '—' : String(net.peersMismatch),
|
||||
variant: net.peersMismatch > 0 ? 'warning' : 'default',
|
||||
footer: (
|
||||
<Badge variant={net.peersMismatch > 0 ? 'warning-light' : 'outline'} size="sm">
|
||||
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : 'в норме'}
|
||||
</Badge>
|
||||
),
|
||||
to: '/network',
|
||||
search: { tab: 'peers' },
|
||||
},
|
||||
{
|
||||
id: 'bird',
|
||||
icon: <Network aria-hidden />,
|
||||
iconClassName:
|
||||
birdHealthy === false ? 'text-destructive' : 'text-primary',
|
||||
label: 'BIRD',
|
||||
value: loading ? '—' : birdSessions,
|
||||
variant: birdHealthy === false ? 'destructive' : 'default',
|
||||
footer: (
|
||||
<Badge
|
||||
variant={
|
||||
birdHealthy === true
|
||||
? 'success-light'
|
||||
: birdHealthy === false
|
||||
? 'destructive-light'
|
||||
: 'outline'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading
|
||||
? '…'
|
||||
: birdHealthy === true
|
||||
? 'в норме'
|
||||
: birdHealthy === false
|
||||
? 'проблема'
|
||||
: 'н/д'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section aria-label="Ключевые метрики сети">
|
||||
<KpiStatGrid cards={cards} isLoading={loading} skeletonCount={4} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,76 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Plus, SearchIcon, ActivityIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
|
||||
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import {
|
||||
getPeerFilterFieldValue,
|
||||
peerColumns,
|
||||
peerTabFilter,
|
||||
} from '@/components/network/network-peers-grid'
|
||||
import {
|
||||
createFilter,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type PeerTab = 'all' | 'established' | 'pending' | 'disabled'
|
||||
/**
|
||||
* BGP peers list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
*/
|
||||
|
||||
const PEER_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'established', label: 'Established' },
|
||||
{ id: 'pending', label: 'Ожидание' },
|
||||
{ id: 'disabled', label: 'Выключены' },
|
||||
]
|
||||
|
||||
const SESSION_STATE_OPTIONS = [
|
||||
{ value: 'Established', label: 'Established' },
|
||||
{ value: 'Idle', label: 'Idle' },
|
||||
{ value: 'Active', label: 'Active' },
|
||||
{ value: 'Connect', label: 'Connect' },
|
||||
{ value: 'OpenSent', label: 'OpenSent' },
|
||||
{ value: 'OpenConfirm', label: 'OpenConfirm' },
|
||||
]
|
||||
|
||||
function createDefaultPeerFilters(): Filter[] {
|
||||
return [createFilter('name', 'contains', [''])]
|
||||
}
|
||||
|
||||
const peerFilterFields: FilterFieldConfig[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Имя',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-48',
|
||||
placeholder: 'Поиск по имени…',
|
||||
},
|
||||
{
|
||||
key: 'neighbor',
|
||||
label: 'Сосед',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-48',
|
||||
placeholder: 'Адрес соседа…',
|
||||
},
|
||||
{
|
||||
key: 'session_state',
|
||||
label: 'Состояние',
|
||||
icon: <ActivityIcon className="size-3.5" aria-hidden />,
|
||||
type: 'select',
|
||||
searchable: false,
|
||||
className: 'w-[160px]',
|
||||
options: SESSION_STATE_OPTIONS,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
|
||||
},
|
||||
]
|
||||
|
||||
interface NetworkPeersCardProps {
|
||||
items: PeerRow[]
|
||||
@@ -22,24 +81,6 @@ interface NetworkPeersCardProps {
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
function filterPeers(items: PeerRow[], tab: PeerTab): PeerRow[] {
|
||||
if (tab === 'all') return items
|
||||
if (tab === 'disabled') return items.filter((p) => p.enabled === false)
|
||||
const enabled = items.filter((p) => p.enabled !== false)
|
||||
if (tab === 'established') return enabled.filter((p) => p.session_state === 'Established')
|
||||
return enabled.filter((p) => p.session_state !== 'Established')
|
||||
}
|
||||
|
||||
function tabCounts(items: PeerRow[]) {
|
||||
const enabled = items.filter((p) => p.enabled !== false)
|
||||
return {
|
||||
all: items.length,
|
||||
established: enabled.filter((p) => p.session_state === 'Established').length,
|
||||
pending: enabled.filter((p) => p.session_state !== 'Established').length,
|
||||
disabled: items.length - enabled.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function NetworkPeersCard({
|
||||
items,
|
||||
speakers,
|
||||
@@ -49,48 +90,44 @@ export function NetworkPeersCard({
|
||||
onRetry,
|
||||
}: NetworkPeersCardProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [tab, setTab] = useState<PeerTab>('all')
|
||||
const counts = useMemo(() => tabCounts(items), [items])
|
||||
const filtered = useMemo(() => filterPeers(items, tab), [items, tab])
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
|
||||
|
||||
const addButton = useMemo(
|
||||
() => (
|
||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||
<Plus />
|
||||
Добавить пира
|
||||
</Button>
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
<ResourcePage
|
||||
title="Пиры"
|
||||
description="BGP-соседи и привязка к спикерам"
|
||||
actions={
|
||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||
<Plus />
|
||||
Добавить пира
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="px-5 pt-3">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as PeerTab)} className="w-full">
|
||||
<TabsList variant="line" className="w-full justify-start gap-6">
|
||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
||||
<TabsTrigger value="established">Established ({counts.established})</TabsTrigger>
|
||||
<TabsTrigger value="pending">Ожидание ({counts.pending})</TabsTrigger>
|
||||
<TabsTrigger value="disabled">Выключены ({counts.disabled})</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<QueryState
|
||||
data={filtered}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={filtered.length === 0}
|
||||
emptyTitle="Нет пиров в выборке"
|
||||
emptyDescription="Измените фильтр или добавьте BGP-соседа."
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
tabs={PEER_TABS}
|
||||
tabFilter={peerTabFilter}
|
||||
filterFields={peerFilterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultPeerFilters())}
|
||||
getFilterFieldValue={getPeerFilterFieldValue}
|
||||
columns={peerColumns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error instanceof Error ? error : null}
|
||||
onRetry={onRetry}
|
||||
primaryAction={addButton}
|
||||
emptyState={{
|
||||
title: 'Нет пиров',
|
||||
description: 'Добавьте первого BGP-соседа.',
|
||||
action: addButton,
|
||||
}}
|
||||
/>
|
||||
|
||||
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
|
||||
</>
|
||||
|
||||
@@ -1,90 +1,77 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||
import type { PeerRow } from '@/types/api'
|
||||
|
||||
export function NetworkPeersGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: PeerRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<PeerRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name ?? row.neighbor,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.name ?? row.original.neighbor}
|
||||
subtitle={row.original.name ? row.original.neighbor : undefined}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'neighbor',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'Адрес соседа' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'remote_asn',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
|
||||
),
|
||||
meta: { headerTitle: 'ASN' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'session_state',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<StatusBadge
|
||||
status={row.original.session_state ?? '—'}
|
||||
label={bgpSessionStateRu(row.original.session_state)}
|
||||
/>
|
||||
{row.original.session_mismatch ? (
|
||||
<CategoryBadge tone="warning">расхождение</CategoryBadge>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name ?? row.neighbor,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.name ?? row.original.neighbor}
|
||||
subtitle={row.original.name ? row.original.neighbor : undefined}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'neighbor',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'Адрес соседа' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'remote_asn',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
|
||||
),
|
||||
meta: { headerTitle: 'ASN' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'session_state',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<StatusBadge
|
||||
status={row.original.session_state ?? '—'}
|
||||
label={bgpSessionStateRu(row.original.session_state)}
|
||||
/>
|
||||
{row.original.session_mismatch ? (
|
||||
<CategoryBadge tone="warning">расхождение</CategoryBadge>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
]
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
return item.name ?? item.neighbor
|
||||
case 'neighbor':
|
||||
return item.neighbor
|
||||
case 'session_state':
|
||||
return item.session_state
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет пиров"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск пиров…"
|
||||
/>
|
||||
)
|
||||
export function peerTabFilter(item: PeerRow, tabId: string): boolean {
|
||||
if (tabId === 'disabled') return item.enabled === false
|
||||
const enabled = item.enabled !== false
|
||||
if (tabId === 'established') return enabled && item.session_state === 'Established'
|
||||
if (tabId === 'pending') return enabled && item.session_state !== 'Established'
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,15 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Plus, SearchIcon, TagIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
|
||||
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import {
|
||||
getSpeakerFilterFieldValue,
|
||||
speakerColumns,
|
||||
speakerTabFilter,
|
||||
} from '@/components/network/network-speakers-grid'
|
||||
import {
|
||||
createFilter,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* BGP speakers list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
*/
|
||||
|
||||
const SPEAKER_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'online', label: 'Online' },
|
||||
{ id: 'offline', label: 'Offline' },
|
||||
]
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'primary', label: 'primary' },
|
||||
{ value: 'secondary', label: 'secondary' },
|
||||
{ value: 'speaker', label: 'speaker' },
|
||||
]
|
||||
|
||||
function createDefaultSpeakerFilters(): Filter[] {
|
||||
return [createFilter('endpoint', 'contains', [''])]
|
||||
}
|
||||
|
||||
const speakerFilterFields: FilterFieldConfig[] = [
|
||||
{
|
||||
key: 'endpoint',
|
||||
label: 'Конечная точка',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'endpoint…',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Роль',
|
||||
icon: <TagIcon className="size-3.5" aria-hidden />,
|
||||
type: 'select',
|
||||
searchable: false,
|
||||
className: 'w-[140px]',
|
||||
options: ROLE_OPTIONS,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, ROLE_OPTIONS),
|
||||
},
|
||||
]
|
||||
|
||||
interface NetworkSpeakersCardProps {
|
||||
items: SpeakerRow[]
|
||||
isLoading: boolean
|
||||
@@ -26,35 +76,44 @@ export function NetworkSpeakersCard({
|
||||
onRetry,
|
||||
}: NetworkSpeakersCardProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters)
|
||||
|
||||
const addButton = useMemo(
|
||||
() => (
|
||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||
<Plus />
|
||||
Добавить спикера
|
||||
</Button>
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
<ResourcePage
|
||||
title="Спикеры"
|
||||
description="BIRD-агенты на нодах tenant"
|
||||
actions={
|
||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||
<Plus />
|
||||
Добавить спикера
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет спикеров"
|
||||
emptyDescription="Добавьте первого BIRD-агента на ноде."
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
tabs={SPEAKER_TABS}
|
||||
tabFilter={speakerTabFilter}
|
||||
filterFields={speakerFilterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
|
||||
getFilterFieldValue={getSpeakerFilterFieldValue}
|
||||
columns={speakerColumns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error instanceof Error ? error : null}
|
||||
onRetry={onRetry}
|
||||
primaryAction={addButton}
|
||||
emptyState={{
|
||||
title: 'Нет спикеров',
|
||||
description: 'Добавьте первого BIRD-агента на ноде.',
|
||||
action: addButton,
|
||||
}}
|
||||
/>
|
||||
|
||||
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
</>
|
||||
|
||||
@@ -1,87 +1,78 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { speakerOnlineLabel } from '@/lib/ui-labels'
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
export function NetworkSpeakersGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: SpeakerRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<SpeakerRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'endpoint',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'Конечная точка' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
enableSorting: false,
|
||||
header: 'Агент',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
|
||||
if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
|
||||
return <Badge variant="outline" size="sm" radius="full">—</Badge>
|
||||
},
|
||||
meta: { headerTitle: 'Агент' },
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
enableSorting: false,
|
||||
header: 'BGP',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (!live) return '—'
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'BGP' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'endpoint',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'Конечная точка' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
enableSorting: false,
|
||||
header: 'Агент',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (live?.agent_ok === true) {
|
||||
return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
|
||||
}
|
||||
if (live?.agent_ok === false) {
|
||||
return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" size="sm" radius="full">
|
||||
—
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Агент' },
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
enableSorting: false,
|
||||
header: 'BGP',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (!live) return '—'
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'BGP' },
|
||||
},
|
||||
]
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
|
||||
switch (field) {
|
||||
case 'endpoint':
|
||||
return item.endpoint
|
||||
case 'role':
|
||||
return item.role
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет спикеров"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск спикеров…"
|
||||
/>
|
||||
)
|
||||
export function speakerTabFilter(item: SpeakerRow, tabId: string): boolean {
|
||||
if (tabId === 'online') return item.live?.agent_ok === true
|
||||
if (tabId === 'offline') return item.live?.agent_ok !== true
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ export {
|
||||
type KpiStatItem,
|
||||
type KpiStatCardData,
|
||||
type KpiStatCard as KpiStatCardType,
|
||||
type KpiStatVariant,
|
||||
type OpsKpiCard,
|
||||
} from './kpi-stat-grid'
|
||||
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||
export { OpsDashboard } from './ops-dashboard'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
|
||||
@@ -2,15 +2,15 @@ import type { KeyboardEvent, ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||
|
||||
/**
|
||||
* KPI tile data — stats-12 visual (icon tile + value + label + footer).
|
||||
* CFDM-compatible: id, label, value, hint?, to?, search?, onSelect?, selected?
|
||||
* EvoBGP: icon?, iconClassName?, footer?, active?, onClick?
|
||||
*
|
||||
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export type KpiStatItem = {
|
||||
@@ -26,6 +26,7 @@ export type KpiStatItem = {
|
||||
active?: boolean
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
variant?: KpiStatVariant
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
@@ -40,7 +41,13 @@ export type KpiStatCard = KpiStatCardData
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
function kpiStatGridClassName(count: number): string {
|
||||
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||
default: 'text-foreground',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
}
|
||||
|
||||
function kpiCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
@@ -65,19 +72,29 @@ function isSelected(item: KpiStatItem): boolean {
|
||||
return Boolean(item.selected ?? item.active)
|
||||
}
|
||||
|
||||
function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
if (item.footer) return item.footer
|
||||
if (typeof item.hint === 'string') {
|
||||
return (
|
||||
<Badge variant="outline" size="sm">
|
||||
{item.hint}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (item.hint) return item.hint
|
||||
return null
|
||||
}
|
||||
|
||||
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
const footer =
|
||||
item.footer ??
|
||||
(item.hint ? (
|
||||
<span className="text-muted-foreground text-xs leading-snug">{item.hint}</span>
|
||||
) : null)
|
||||
const footer = resolveFooter(item)
|
||||
const valueVariant = item.variant ?? 'default'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{item.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
>
|
||||
@@ -87,19 +104,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
</Item>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||
{footer ? <div className="shrink-0">{footer}</div> : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-2xl leading-none font-bold tabular-nums',
|
||||
VALUE_VARIANT_CLASS[valueVariant],
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||
</div>
|
||||
|
||||
{footer ? <div className="mt-auto w-full">{footer}</div> : null}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Single KPI tile (ReUI stats-12 / dashboard PRO pattern). */
|
||||
function panelClassName(item: KpiStatItem, className?: string) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const clickable = Boolean(item.to || onActivate)
|
||||
const selected = isSelected(item)
|
||||
|
||||
return cn(
|
||||
'relative isolate flex h-full flex-col',
|
||||
clickable &&
|
||||
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||
className,
|
||||
)
|
||||
}
|
||||
|
||||
/** Single KPI tile — used for embedded / standalone contexts. */
|
||||
export function KpiStatCardTile({
|
||||
item,
|
||||
embedded = false,
|
||||
@@ -110,26 +147,14 @@ export function KpiStatCardTile({
|
||||
className?: string
|
||||
}) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const clickable = Boolean(item.to || onActivate)
|
||||
const selected = isSelected(item)
|
||||
|
||||
const panelClass = cn(
|
||||
'flex h-full flex-col items-start gap-6',
|
||||
clickable && 'cursor-pointer transition-colors hover:bg-muted/30',
|
||||
selected && 'ring-1 ring-primary/30',
|
||||
className,
|
||||
)
|
||||
const panelClass = panelClassName(item, className)
|
||||
|
||||
let panel: ReactNode
|
||||
|
||||
if (item.to) {
|
||||
panel = (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link
|
||||
to={item.to}
|
||||
search={item.search}
|
||||
className="flex h-full w-full flex-col items-start gap-6 focus-visible:outline-none"
|
||||
>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
@@ -176,22 +201,22 @@ export function KpiStatCard({
|
||||
|
||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||
return (
|
||||
<section className="@container w-full" aria-label="Загрузка показателей">
|
||||
<div className={cn('grid gap-5', kpiStatGridClassName(count))}>
|
||||
<Frame className="@container w-full">
|
||||
<div className={cn('grid gap-2', kpiCols(count))}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<Frame key={index} className="h-full">
|
||||
<FramePanel className="flex h-full flex-col items-start gap-6">
|
||||
<Skeleton className="size-10.5 rounded-md" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<FramePanel key={index} className="flex items-start gap-3">
|
||||
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="mt-auto h-5 w-32 rounded-full" />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<Skeleton className="h-7 w-16" />
|
||||
</div>
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,10 +230,50 @@ interface KpiStatGridProps {
|
||||
emptyIcon?: ReactNode
|
||||
className?: string
|
||||
skeletonCount?: number
|
||||
/** Wrap each tile in its own Frame (analytics panels). */
|
||||
embedded?: boolean
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item)
|
||||
|
||||
if (item.to) {
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (onActivate) {
|
||||
return (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function KpiStatGrid({
|
||||
items,
|
||||
cards,
|
||||
@@ -239,18 +304,26 @@ export function KpiStatGrid({
|
||||
)
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||
<div className={cn('grid gap-5', kpiStatGridClassName(list.length || 1))}>
|
||||
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardTile
|
||||
key={kpiStatItemKey(item, index)}
|
||||
item={item}
|
||||
embedded={embedded}
|
||||
/>
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export interface QuickActionItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
to: string
|
||||
search?: Record<string, unknown>
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
}
|
||||
|
||||
interface QuickActionGridProps {
|
||||
actions: QuickActionItem[]
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
function kpiCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
}
|
||||
|
||||
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{action.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{action.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||
<Badge variant="outline" size="sm" className="shrink-0">
|
||||
Перейти
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function QuickActionGrid({
|
||||
actions,
|
||||
title = 'Быстрые действия',
|
||||
description,
|
||||
className,
|
||||
}: QuickActionGridProps) {
|
||||
if (actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
|
||||
{(title || description) && (
|
||||
<FrameHeader>
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</FrameHeader>
|
||||
)}
|
||||
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
||||
{actions.map((action) => (
|
||||
<FramePanel
|
||||
key={action.id}
|
||||
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
|
||||
>
|
||||
<Link
|
||||
to={action.to}
|
||||
search={action.search}
|
||||
className="focus-visible:outline-none"
|
||||
aria-label={`${action.title}: ${action.description}`}
|
||||
>
|
||||
<QuickActionBody action={action} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -32,15 +32,17 @@ function hintToFooter(hint: ReactNode) {
|
||||
}
|
||||
|
||||
function toKpiStatItem(item: SectionCardItem, index: number): KpiStatItem {
|
||||
const variant = item.variant ?? 'default'
|
||||
const footer =
|
||||
item.badge ?? (item.hint ? hintToFooter(item.hint) : undefined)
|
||||
|
||||
return {
|
||||
id: typeof item.label === 'string' ? item.label : `section-${index}`,
|
||||
icon: item.icon,
|
||||
iconClassName: VARIANT_ICON_CLASS[item.variant ?? 'default'],
|
||||
iconClassName: VARIANT_ICON_CLASS[variant],
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
variant,
|
||||
footer,
|
||||
active: item.active,
|
||||
onClick: item.onClick,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Moon, Sun, SunMoon } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||
@@ -9,6 +11,9 @@ import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@evobgp/ui/components/toggle-group'
|
||||
import { Switch } from '@evobgp/ui/components/switch'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import { settingsKeys, settingsQueryOptions } from '@/queries/settings'
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: 'light', label: 'Светлая', icon: Sun },
|
||||
@@ -16,8 +21,27 @@ const THEME_OPTIONS = [
|
||||
{ value: 'system', label: 'Система', icon: SunMoon },
|
||||
] as const
|
||||
|
||||
function parseShowQuickActions(value: unknown): boolean {
|
||||
if (value === false || value === 0 || value === 'false' || value === '0') return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function AppearanceSettingsTab() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const qc = useQueryClient()
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
|
||||
|
||||
const patchMut = useMutation({
|
||||
mutationFn: (payload: Record<string, boolean>) =>
|
||||
apiMutate('/v1/settings', 'PATCH', payload),
|
||||
onSuccess: () => {
|
||||
toast.success('Настройки интерфейса сохранены')
|
||||
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -63,6 +87,31 @@ export function AppearanceSettingsTab() {
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Дашборд"
|
||||
description="Блоки на экране «Обзор»"
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Быстрые действия"
|
||||
description="Показывать KPI-like плитки быстрых переходов под метриками."
|
||||
>
|
||||
<SettingRow
|
||||
title="Быстрые действия"
|
||||
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде."
|
||||
last
|
||||
>
|
||||
<Switch
|
||||
checked={showQuickActions}
|
||||
disabled={settingsQ.isLoading || patchMut.isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
patchMut.mutate({ ui_show_quick_actions: checked })
|
||||
}
|
||||
aria-label="Показывать быстрые действия"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
DEFAULT_APP_SWITCHER_CONFIG,
|
||||
getAppUrl as getAppUrlFromConfig,
|
||||
type AppSwitcherConfig,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
|
||||
import { getClaims, isAuthEnabled } from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* Portal-first switcher: tries `/api/v1/app-switcher` and filters entries by
|
||||
* the current JWT `apps` claim; falls back to `VITE_APP_SWITCHER`/defaults
|
||||
* when auth-portal is disabled or unavailable.
|
||||
*/
|
||||
export function useAppSwitcherConfig(): {
|
||||
config: AppSwitcherConfig
|
||||
isLoading: boolean
|
||||
} {
|
||||
const authOn = isAuthEnabled()
|
||||
const { data, isLoading } = useQuery({
|
||||
...appSwitcherQueryOptions(),
|
||||
enabled: authOn,
|
||||
})
|
||||
const claims = getClaims()
|
||||
|
||||
const config = useMemo<AppSwitcherConfig>(() => {
|
||||
const raw = data ?? DEFAULT_APP_SWITCHER_CONFIG
|
||||
if (!authOn) return raw
|
||||
const allowed = claims?.apps
|
||||
if (!allowed?.length) return raw
|
||||
const set = new Set(allowed)
|
||||
const apps = raw.apps.filter((a) => set.has(a.id))
|
||||
if (!apps.length) return raw
|
||||
return { ...raw, apps }
|
||||
}, [data, authOn, claims?.apps])
|
||||
|
||||
return { config, isLoading: authOn && isLoading }
|
||||
}
|
||||
|
||||
export function useAppUrl(appId: string): string | undefined {
|
||||
const { config } = useAppSwitcherConfig()
|
||||
return getAppUrlFromConfig(appId, config ?? DEFAULT_APP_SWITCHER_CONFIG)
|
||||
}
|
||||
@@ -30,8 +30,19 @@ export function normalizeApiToken(raw: string): string {
|
||||
return t
|
||||
}
|
||||
|
||||
/** Portal JWT storage key mirrored from `@/lib/auth`. Kept local to avoid a
|
||||
* cycle when `auth` starts pulling from `api-client` for the config endpoint. */
|
||||
const PORTAL_TOKEN_STORAGE_KEY = 'evobgp_portal_token'
|
||||
|
||||
/**
|
||||
* Bearer selection: portal JWT wins over the legacy API-key. When auth-portal
|
||||
* is disabled or hasn't issued a token yet we fall back to the local API-key
|
||||
* (`evobgp_api_token`) so curl-style tooling keeps working.
|
||||
*/
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const portal = window.localStorage.getItem(PORTAL_TOKEN_STORAGE_KEY)
|
||||
if (portal && portal.trim()) return portal.trim()
|
||||
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const normalized = normalizeApiToken(raw)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
ChartBarIcon,
|
||||
CloudIcon,
|
||||
GlobeIcon,
|
||||
LayoutDashboardIcon,
|
||||
ServerIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { CURRENT_APP_ID } from '@/lib/auth'
|
||||
|
||||
export { CURRENT_APP_ID }
|
||||
|
||||
const appSwitcherIconSchema = z.enum(['server', 'cloud', 'globe', 'dashboard', 'chart'])
|
||||
|
||||
export type AppSwitcherIconName = z.infer<typeof appSwitcherIconSchema>
|
||||
|
||||
export const APP_SWITCHER_ICONS: Record<AppSwitcherIconName, LucideIcon> = {
|
||||
server: ServerIcon,
|
||||
cloud: CloudIcon,
|
||||
globe: GlobeIcon,
|
||||
dashboard: LayoutDashboardIcon,
|
||||
chart: ChartBarIcon,
|
||||
}
|
||||
|
||||
const appSwitcherEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
url: z.string(),
|
||||
icon: appSwitcherIconSchema.default('server'),
|
||||
shortcut: z.string().optional(),
|
||||
})
|
||||
|
||||
const appSwitcherConfigSchema = z.object({
|
||||
menuLabel: z.string().default('Приложения'),
|
||||
apps: z.array(appSwitcherEntrySchema).min(1),
|
||||
})
|
||||
|
||||
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
|
||||
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
|
||||
|
||||
/** Shared defaults across ops apps — chrome app switcher. */
|
||||
export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
|
||||
menuLabel: 'Приложения',
|
||||
apps: [
|
||||
{
|
||||
id: 'vps',
|
||||
name: 'VPS Tracker',
|
||||
subtitle: 'Учёт виртуальных серверов',
|
||||
url: 'http://192.168.100.67:3001',
|
||||
icon: 'server',
|
||||
shortcut: '⌘1',
|
||||
},
|
||||
{
|
||||
id: 'cfdm',
|
||||
name: 'CF Domain Manager',
|
||||
subtitle: 'Управление доменами',
|
||||
url: 'http://192.168.100.67:6363',
|
||||
icon: 'cloud',
|
||||
shortcut: '⌘2',
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
name: 'EvoBGP',
|
||||
subtitle: 'BGP маршрутизация',
|
||||
url: 'http://192.168.100.67:3000',
|
||||
icon: 'globe',
|
||||
shortcut: '⌘3',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function parseAppSwitcherConfig(raw?: string): AppSwitcherConfig {
|
||||
if (!raw?.trim()) {
|
||||
return DEFAULT_APP_SWITCHER_CONFIG
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return appSwitcherConfigSchema.parse(parsed)
|
||||
} catch (error) {
|
||||
console.warn('Invalid VITE_APP_SWITCHER, using defaults:', error)
|
||||
return DEFAULT_APP_SWITCHER_CONFIG
|
||||
}
|
||||
}
|
||||
|
||||
export function getAppSwitcherConfig(): AppSwitcherConfig {
|
||||
return parseAppSwitcherConfig(import.meta.env.VITE_APP_SWITCHER)
|
||||
}
|
||||
|
||||
export function getAppUrl(
|
||||
appId: string,
|
||||
config: AppSwitcherConfig = getAppSwitcherConfig(),
|
||||
): string | undefined {
|
||||
return config.apps.find((app) => app.id === appId)?.url
|
||||
}
|
||||
|
||||
export function getCurrentApp(
|
||||
config: AppSwitcherConfig = getAppSwitcherConfig(),
|
||||
): AppSwitcherEntry {
|
||||
return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]!
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Portal JWT SSO integration for EvoBGP UI.
|
||||
*
|
||||
* Two independent auth channels:
|
||||
* - Portal JWT (this module) — SSO from auth-portal, used to gate UI + Bearer
|
||||
* to EvoBGP API when the API accepts portal-issued tokens.
|
||||
* - Local API key (see `@/lib/api-client`) — legacy `evobgp_api_token`
|
||||
* in localStorage; used when auth-portal is disabled or as a fallback.
|
||||
*
|
||||
* `VITE_AUTH_ENABLED=false` → keep the API-key gate.
|
||||
* `VITE_AUTH_ENABLED=true` → require portal JWT; API-key kept only for tools
|
||||
* (curl/dev) and as backup.
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = 'evobgp_portal_token'
|
||||
const HANDOFF_KEY = 'evobgp_portal_401_handoff'
|
||||
const HANDOFF_AT_KEY = 'evobgp_portal_handoff_at'
|
||||
/** Min gap between portal handoffs — breaks SSO↔401 redirect storms. */
|
||||
const HANDOFF_COOLDOWN_MS = 12_000
|
||||
|
||||
/** EvoBGP is `bgp` in the auth-portal registry (see APP_IDS). */
|
||||
export const CURRENT_APP_ID = 'bgp'
|
||||
|
||||
export type AccessClaims = {
|
||||
sub: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
is_admin?: boolean
|
||||
iss?: string
|
||||
exp?: number
|
||||
}
|
||||
|
||||
export type RuntimeAuthConfig = {
|
||||
required: boolean
|
||||
portalUrl: string
|
||||
}
|
||||
|
||||
let runtimeConfig: RuntimeAuthConfig | null = null
|
||||
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
|
||||
|
||||
function viteAuthEnabled(): boolean {
|
||||
return (
|
||||
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
|
||||
import.meta.env.VITE_AUTH_ENABLED === '1'
|
||||
)
|
||||
}
|
||||
|
||||
function vitePortalUrl(): string {
|
||||
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load auth mode from EvoBGP API (Docker-friendly). Falls back to VITE_* flags
|
||||
* when the endpoint isn't implemented (404) or the API is unreachable.
|
||||
*
|
||||
* Note: EvoBGP uses `/v1/...` (not `/api/v1/...`).
|
||||
*/
|
||||
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
|
||||
if (runtimeConfig) return runtimeConfig
|
||||
if (runtimeConfigPromise) return runtimeConfigPromise
|
||||
|
||||
runtimeConfigPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch('/v1/auth/config', {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as {
|
||||
required?: boolean
|
||||
portal_url?: string
|
||||
}
|
||||
runtimeConfig = {
|
||||
required: Boolean(data.required) || viteAuthEnabled(),
|
||||
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
|
||||
}
|
||||
return runtimeConfig
|
||||
}
|
||||
} catch {
|
||||
/* ignore — fall through to vite defaults */
|
||||
}
|
||||
runtimeConfig = {
|
||||
required: viteAuthEnabled(),
|
||||
portalUrl: vitePortalUrl(),
|
||||
}
|
||||
return runtimeConfig
|
||||
})().finally(() => {
|
||||
runtimeConfigPromise = null
|
||||
})
|
||||
|
||||
return runtimeConfigPromise
|
||||
}
|
||||
|
||||
export function getAuthConfigSync(): RuntimeAuthConfig | null {
|
||||
return runtimeConfig
|
||||
}
|
||||
|
||||
export function getPortalToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setPortalToken(token: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearPortalToken(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function isAuthEnabled(): boolean {
|
||||
if (runtimeConfig) return runtimeConfig.required
|
||||
return viteAuthEnabled()
|
||||
}
|
||||
|
||||
export function authPortalUrl(): string {
|
||||
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
|
||||
return vitePortalUrl()
|
||||
}
|
||||
|
||||
export function isPortalHandoffCoolingDown(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
const raw = window.sessionStorage.getItem(HANDOFF_AT_KEY)
|
||||
if (!raw) return false
|
||||
const at = Number(raw)
|
||||
if (!Number.isFinite(at)) return false
|
||||
return Date.now() - at < HANDOFF_COOLDOWN_MS
|
||||
}
|
||||
|
||||
export function markPortalHandoff(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.sessionStorage.setItem(HANDOFF_KEY, '1')
|
||||
window.sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
|
||||
}
|
||||
|
||||
export function clearPortalHandoffFlag(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.sessionStorage.removeItem(HANDOFF_KEY)
|
||||
}
|
||||
|
||||
/** Clear cooldown too — use on intentional logout so next login is allowed. */
|
||||
export function resetPortalHandoff(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.sessionStorage.removeItem(HANDOFF_KEY)
|
||||
window.sessionStorage.removeItem(HANDOFF_AT_KEY)
|
||||
}
|
||||
|
||||
export function hasPortalHandoffFlag(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.sessionStorage.getItem(HANDOFF_KEY) === '1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to auth-portal SSO. Returns false if cooldown blocks the handoff
|
||||
* (clears local token) — prevents infinite SSO when API rejects JWT.
|
||||
*/
|
||||
export function redirectToPortalLogin(returnTo?: string): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (isPortalHandoffCoolingDown()) {
|
||||
clearPortalToken()
|
||||
return false
|
||||
}
|
||||
markPortalHandoff()
|
||||
const callback = returnTo ?? `${window.location.origin}/auth/callback`
|
||||
const url = new URL(authPortalUrl())
|
||||
url.searchParams.set('return_to', callback)
|
||||
window.location.assign(url.toString())
|
||||
return true
|
||||
}
|
||||
|
||||
/** End portal SSO session (refresh cookie + portal token). */
|
||||
export function redirectToPortalLogout(): void {
|
||||
clearPortalToken()
|
||||
resetPortalHandoff()
|
||||
if (typeof window === 'undefined') return
|
||||
window.location.assign(`${authPortalUrl()}/logout`)
|
||||
}
|
||||
|
||||
export function parseHashToken(hash: string): {
|
||||
accessToken: string | null
|
||||
expiresAt: string | null
|
||||
} {
|
||||
const raw = hash.startsWith('#') ? hash.slice(1) : hash
|
||||
const params = new URLSearchParams(raw)
|
||||
return {
|
||||
accessToken: params.get('access_token'),
|
||||
expiresAt: params.get('expires_at'),
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeClaims(token: string): AccessClaims | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length < 2) return null
|
||||
const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
const payload = JSON.parse(json) as Record<string, unknown>
|
||||
return {
|
||||
sub: String(payload.sub ?? ''),
|
||||
email: String(payload.email ?? ''),
|
||||
name: String(payload.name ?? ''),
|
||||
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
|
||||
permissions: Array.isArray(payload.permissions)
|
||||
? payload.permissions.map(String)
|
||||
: [],
|
||||
is_admin: Boolean(payload.is_admin),
|
||||
iss: payload.iss ? String(payload.iss) : undefined,
|
||||
exp: typeof payload.exp === 'number' ? payload.exp : undefined,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getClaims(): AccessClaims | null {
|
||||
const token = getPortalToken()
|
||||
if (!token) return null
|
||||
const claims = decodeClaims(token)
|
||||
if (!claims) return null
|
||||
if (claims.exp && claims.exp * 1000 < Date.now()) {
|
||||
clearPortalToken()
|
||||
return null
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
granted: readonly string[],
|
||||
required: string,
|
||||
): boolean {
|
||||
if (granted.includes(required)) return true
|
||||
const parts = required.split(':')
|
||||
if (parts.length !== 3) return false
|
||||
const [app, section, action] = parts
|
||||
if (action === 'read') {
|
||||
return (
|
||||
granted.includes(`${app}:${section}:write`) ||
|
||||
granted.includes(`${app}:${section}:admin`)
|
||||
)
|
||||
}
|
||||
if (action === 'write') {
|
||||
return granted.includes(`${app}:${section}:admin`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Access-check: pass when portal auth is disabled or claim grants required. */
|
||||
export function can(required: string): boolean {
|
||||
if (!isAuthEnabled()) return true
|
||||
const claims = getClaims()
|
||||
if (!claims) return false
|
||||
if (!claims.apps.includes(CURRENT_APP_ID)) return false
|
||||
if (claims.is_admin) return true
|
||||
return hasPermission(claims.permissions, required)
|
||||
}
|
||||
|
||||
/** 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')) {
|
||||
return 'bgp:dashboard:read'
|
||||
}
|
||||
if (pathname.startsWith('/modules')) return 'bgp:modules:read'
|
||||
if (pathname.startsWith('/lookup')) return 'bgp:lookup:read'
|
||||
if (pathname.startsWith('/network')) return 'bgp:network:read'
|
||||
if (pathname.startsWith('/directories')) return 'bgp:directories:read'
|
||||
if (pathname.startsWith('/operations')) return 'bgp:operations:read'
|
||||
if (pathname.startsWith('/firewall')) return 'bgp:firewall:read'
|
||||
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
||||
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
||||
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
||||
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
|
||||
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
|
||||
return null
|
||||
}
|
||||
|
||||
const FALLBACK_PATH = '/dashboard'
|
||||
|
||||
/** First path in the sidebar the current user may open. */
|
||||
export function firstAllowedPath(): string {
|
||||
const candidates: readonly string[] = [
|
||||
'/dashboard',
|
||||
'/modules',
|
||||
'/lookup',
|
||||
'/network',
|
||||
'/directories',
|
||||
'/operations',
|
||||
'/firewall',
|
||||
'/schedule',
|
||||
'/monitoring',
|
||||
'/access',
|
||||
'/tenant-settings',
|
||||
'/settings',
|
||||
]
|
||||
for (const path of candidates) {
|
||||
const perm = permissionForPath(path)
|
||||
if (!perm || can(perm)) return path
|
||||
}
|
||||
return FALLBACK_PATH
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { ensureAuthConfig } from '@/lib/auth'
|
||||
import {
|
||||
DEFAULT_APP_SWITCHER_CONFIG,
|
||||
parseAppSwitcherConfig,
|
||||
type AppSwitcherConfig,
|
||||
} from '@/lib/app-switcher-config'
|
||||
|
||||
export const appSwitcherQueryKey = ['app-switcher', 'portal'] as const
|
||||
|
||||
/** Portal contract shape: `{ menuLabel, apps: [{ id, name, url, icon, enabled }] }`. */
|
||||
async function fetchPortalAppSwitcher(): Promise<AppSwitcherConfig> {
|
||||
const { portalUrl } = await ensureAuthConfig()
|
||||
const base = portalUrl.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/api/v1/app-switcher`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) throw new Error(`app-switcher ${res.status}`)
|
||||
const raw = (await res.json()) as unknown
|
||||
return parseAppSwitcherConfig(JSON.stringify(raw))
|
||||
}
|
||||
|
||||
export function appSwitcherQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: appSwitcherQueryKey,
|
||||
queryFn: fetchPortalAppSwitcher,
|
||||
staleTime: 60_000,
|
||||
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
|
||||
retry: 1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type { LookupResponse } from '@/types/api'
|
||||
|
||||
export const lookupKeys = {
|
||||
all: ['lookup'] as const,
|
||||
query: (q: string) => [...lookupKeys.all, q] as const,
|
||||
}
|
||||
|
||||
/** GET /v1/lookup?q= — dual-layer membership (entry + snapshot). */
|
||||
export function lookupQueryOptions(q: string) {
|
||||
const trimmed = q.trim()
|
||||
return queryOptions<LookupResponse>({
|
||||
queryKey: lookupKeys.query(trimmed),
|
||||
queryFn: () =>
|
||||
apiJSON<LookupResponse>(`/v1/lookup?q=${encodeURIComponent(trimmed)}`),
|
||||
enabled: trimmed.length > 0,
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
@@ -1,15 +1,69 @@
|
||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||
|
||||
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||
import {
|
||||
can,
|
||||
ensureAuthConfig,
|
||||
firstAllowedPath,
|
||||
getClaims,
|
||||
getPortalToken,
|
||||
permissionForPath,
|
||||
redirectToPortalLogin,
|
||||
} from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* Two-mode gate:
|
||||
* - VITE_AUTH_ENABLED / API `/v1/auth/config { required: true }`
|
||||
* → require auth-portal JWT (SSO) + section permission via `can()`.
|
||||
* - Off → keep legacy `evobgp_api_token` gate (redirect to /settings if empty).
|
||||
*/
|
||||
export const Route = createFileRoute('/_auth')({
|
||||
beforeLoad: ({ location }) => {
|
||||
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
|
||||
beforeLoad: async ({ location }) => {
|
||||
const cfg = await ensureAuthConfig()
|
||||
|
||||
if (cfg.required) {
|
||||
const token = getPortalToken()
|
||||
const claims = getClaims()
|
||||
if (!token || !claims) {
|
||||
const ok = redirectToPortalLogin(
|
||||
`${window.location.origin}/auth/callback`,
|
||||
)
|
||||
if (!ok) {
|
||||
throw redirect({
|
||||
to: '/auth/callback',
|
||||
search: { error: 'sso_loop' },
|
||||
})
|
||||
}
|
||||
await new Promise(() => {})
|
||||
return
|
||||
}
|
||||
if (!claims.apps.includes('bgp')) {
|
||||
throw redirect({
|
||||
to: '/auth/callback',
|
||||
search: { error: 'sso_loop' },
|
||||
})
|
||||
}
|
||||
const perm = permissionForPath(location.pathname)
|
||||
if (perm && !can(perm)) {
|
||||
const fallback = firstAllowedPath()
|
||||
if (fallback !== location.pathname) {
|
||||
throw redirect({ to: fallback as '/dashboard' })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Settings available without token — first-run onboarding (incl. `dev`).
|
||||
if (location.pathname === '/settings') return
|
||||
const raw =
|
||||
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
|
||||
typeof window !== 'undefined'
|
||||
? window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
: null
|
||||
if (!raw || !normalizeApiToken(raw)) {
|
||||
throw redirect({ to: '/settings', search: { tab: 'connection', reason: 'token-required' } })
|
||||
throw redirect({
|
||||
to: '/settings',
|
||||
search: { tab: 'connection', reason: 'token-required' },
|
||||
})
|
||||
}
|
||||
},
|
||||
component: AuthLayout,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
@@ -27,13 +27,21 @@ import {
|
||||
overviewRevisionsQueryOptions,
|
||||
overviewSpeakersQueryOptions,
|
||||
} from '@/queries/overview'
|
||||
import { settingsQueryOptions } from '@/queries/settings'
|
||||
|
||||
export const Route = createFileRoute('/_auth/dashboard')({
|
||||
component: DashboardComponent,
|
||||
})
|
||||
|
||||
function parseShowQuickActions(value: unknown): boolean {
|
||||
if (value === false || value === 0 || value === 'false' || value === '0') return false
|
||||
return true
|
||||
}
|
||||
|
||||
function DashboardComponent() {
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
|
||||
|
||||
const results = useQueries({
|
||||
queries: [
|
||||
@@ -86,8 +94,6 @@ function DashboardComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<DashboardQuickLinks />
|
||||
|
||||
{initialLoading ? (
|
||||
<AnalyticsDashboardSkeleton />
|
||||
) : (
|
||||
@@ -99,6 +105,8 @@ function DashboardComponent() {
|
||||
jobs={jobs}
|
||||
/>
|
||||
|
||||
{showQuickActions ? <DashboardQuickLinks /> : null}
|
||||
|
||||
<div className={dashboardMainSidebarClassName}>
|
||||
<div className="xl:col-span-8">
|
||||
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { LookupMatchesGrid } from '@/components/lookup/lookup-matches-grid'
|
||||
import { LookupSearchForm } from '@/components/lookup/lookup-search-form'
|
||||
import { LookupSummaryKpi } from '@/components/lookup/lookup-summary-kpi'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||
import { lookupQueryOptions } from '@/queries/lookup'
|
||||
|
||||
/**
|
||||
* Quick membership lookup page.
|
||||
* Surface: frame · KPI: stats-12 · form: form-7 · grid: data-grid-filtering-2 · empty: empty-state-2
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
* @see https://reui.io/preview/base/form-7
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see https://reui.io/preview/base/empty-state-2
|
||||
*/
|
||||
export const Route = createFileRoute('/_auth/lookup')({
|
||||
component: LookupComponent,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
q: typeof search.q === 'string' ? search.q : '',
|
||||
}),
|
||||
})
|
||||
|
||||
function LookupComponent() {
|
||||
const { q } = useSearch({ from: '/_auth/lookup' })
|
||||
const navigate = Route.useNavigate()
|
||||
const lookupQ = useQuery(lookupQueryOptions(q))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<PageHeader
|
||||
title="Проверка"
|
||||
description="Быстрая проверка IP или домена в списках и community (entry + snapshot)"
|
||||
/>
|
||||
|
||||
<LookupSearchForm
|
||||
key={q}
|
||||
initialQuery={q}
|
||||
isPending={lookupQ.isFetching}
|
||||
onSubmit={(next) => void navigate({ search: { q: next } })}
|
||||
/>
|
||||
|
||||
{!q.trim() ? (
|
||||
<EmptyState
|
||||
icon={<Search className="size-8" />}
|
||||
title="Введите IP или домен"
|
||||
description="Например 8.8.8.8 или example.com — проверка по сырым entries и материализованным префиксам."
|
||||
/>
|
||||
) : (
|
||||
<QueryState
|
||||
data={lookupQ.data}
|
||||
isLoading={lookupQ.isLoading}
|
||||
isError={lookupQ.isError}
|
||||
error={lookupQ.error}
|
||||
onRetry={() => void lookupQ.refetch()}
|
||||
skeleton={
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<SectionCardsSkeleton />
|
||||
<TableSkeleton rows={5} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(data) => (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<LookupSummaryKpi data={data} />
|
||||
{data.matched ? (
|
||||
<LookupMatchesGrid items={data.matches} isLoading={lookupQ.isFetching} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Search className="size-8" />}
|
||||
title="Не найдено в списках"
|
||||
description={`«${data.normalized}» отсутствует в entries и snapshots tenant.`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,38 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { TabsContent } from '@evobgp/ui/components/tabs'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
import {
|
||||
DashboardNetworkCapacityCard,
|
||||
NetworkOverviewAnalyticsCard,
|
||||
} from '@/components/analytics'
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { NetworkKpi } from '@/components/network/network-kpi'
|
||||
import { NetworkPeersCard } from '@/components/network/network-peers-card'
|
||||
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
|
||||
import { overviewJobsQueryOptions } from '@/queries/overview'
|
||||
import {
|
||||
networkBirdQueryOptions,
|
||||
networkPeersQueryOptions,
|
||||
networkSpeakersQueryOptions,
|
||||
} from '@/queries/network'
|
||||
|
||||
type NetworkTab = 'peers' | 'speakers'
|
||||
|
||||
function parseNetworkTab(value: unknown): NetworkTab {
|
||||
if (value === 'speakers') return 'speakers'
|
||||
// legacy: overview | control-plane → peers
|
||||
return 'peers'
|
||||
}
|
||||
|
||||
/**
|
||||
* Network ops page — KPI (stats-12) + peers/speakers ResourcePage lists.
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see https://reui.io/preview/base/empty-state-12
|
||||
*/
|
||||
export const Route = createFileRoute('/_auth/network')({
|
||||
component: NetworkComponent,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab === 'peers' || search.tab === 'speakers' || search.tab === 'control-plane'
|
||||
? search.tab
|
||||
: 'overview') as 'overview' | 'peers' | 'speakers' | 'control-plane',
|
||||
tab: parseNetworkTab(search.tab),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -32,12 +42,10 @@ function NetworkComponent() {
|
||||
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
||||
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||
const jobsQ = useQuery(overviewJobsQueryOptions())
|
||||
|
||||
const refreshing = peersQ.isFetching || speakersQ.isFetching
|
||||
const refreshing = peersQ.isFetching || speakersQ.isFetching || birdQ.isFetching
|
||||
const peers = peersQ.data?.items ?? []
|
||||
const speakers = speakersQ.data?.items ?? []
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
|
||||
|
||||
function refetchAll() {
|
||||
@@ -47,121 +55,56 @@ function NetworkComponent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<PageHeader
|
||||
title="Сеть"
|
||||
description="BGP-пиры, спикеры и live-метрики нод"
|
||||
description="BGP-пиры, спикеры и статус BIRD"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
|
||||
Обновить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<BadgeTabs
|
||||
<NetworkKpi
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
bird={birdQ.data}
|
||||
loading={overviewLoading || birdQ.isLoading}
|
||||
/>
|
||||
|
||||
<CountedLineTabs
|
||||
value={search.tab}
|
||||
onValueChange={(tab) =>
|
||||
navigate({
|
||||
search: {
|
||||
tab: tab as 'overview' | 'peers' | 'speakers' | 'control-plane',
|
||||
},
|
||||
})
|
||||
navigate({ search: { tab: tab as NetworkTab } })
|
||||
}
|
||||
items={[
|
||||
{ value: 'overview', label: 'Обзор' },
|
||||
{ value: 'peers', label: 'Пиры', count: peers.length },
|
||||
{ value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' },
|
||||
{ value: 'control-plane', label: 'Плоскость управления' },
|
||||
tabs={[
|
||||
{ id: 'peers', label: 'Пиры', count: peers.length },
|
||||
{ id: 'speakers', label: 'Спикеры', count: speakers.length },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="overview" className="mt-0">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<NetworkOverviewAnalyticsCard
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
loading={overviewLoading}
|
||||
/>
|
||||
<DashboardNetworkCapacityCard
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
jobs={jobs}
|
||||
loading={overviewLoading}
|
||||
/>
|
||||
</div>
|
||||
<PanelCard
|
||||
className="mt-4"
|
||||
title="BIRD (control plane)"
|
||||
description="Статус birdc на хосте API"
|
||||
contentClassName="py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={birdQ.data}
|
||||
isLoading={birdQ.isLoading}
|
||||
isError={birdQ.isError}
|
||||
error={birdQ.error}
|
||||
skeleton={<TableSkeleton rows={3} cols={2} />}
|
||||
onRetry={() => birdQ.refetch()}
|
||||
>
|
||||
{(bird) => <BirdSummary bird={bird} />}
|
||||
</QueryState>
|
||||
</PanelCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="peers" className="mt-0">
|
||||
<TabsContent value="peers" className="mt-4">
|
||||
<NetworkPeersCard
|
||||
items={peers}
|
||||
speakers={speakers}
|
||||
isLoading={peersQ.isLoading}
|
||||
isError={peersQ.isError}
|
||||
error={peersQ.error}
|
||||
onRetry={() => peersQ.refetch()}
|
||||
onRetry={() => void peersQ.refetch()}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="speakers" className="mt-0">
|
||||
<TabsContent value="speakers" className="mt-4">
|
||||
<NetworkSpeakersCard
|
||||
items={speakers}
|
||||
isLoading={speakersQ.isLoading}
|
||||
isError={speakersQ.isError}
|
||||
error={speakersQ.error}
|
||||
onRetry={() => speakersQ.refetch()}
|
||||
onRetry={() => void speakersQ.refetch()}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="control-plane" className="mt-0">
|
||||
<PanelCard
|
||||
title="Настройки Control Plane (BIRD)"
|
||||
description="Конфигурация tenant-level — в разделе «Настройки BIRD»"
|
||||
contentClassName="py-4 text-sm text-muted-foreground"
|
||||
>
|
||||
См. раздел «Настройки BIRD».
|
||||
</PanelCard>
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<Field
|
||||
label="Состояние"
|
||||
value={bird.healthy === true ? 'В норме' : bird.healthy === false ? 'Проблема' : 'Н/Д'}
|
||||
/>
|
||||
<Field label="Сессий BGP" value={`${bird.bgp_established} / ${bird.bgp_sessions_total}`} />
|
||||
{bird.message ? <p className="text-xs text-muted-foreground">{bird.message}</p> : null}
|
||||
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
|
||||
</CountedLineTabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
import {
|
||||
authPortalUrl,
|
||||
clearPortalHandoffFlag,
|
||||
clearPortalToken,
|
||||
ensureAuthConfig,
|
||||
firstAllowedPath,
|
||||
getClaims,
|
||||
getPortalToken,
|
||||
parseHashToken,
|
||||
redirectToPortalLogin,
|
||||
setPortalToken,
|
||||
} from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* SSO callback — reads `#access_token=…&expires_at=…` returned by auth-portal,
|
||||
* stores the JWT, and drops the user on the first allowed page.
|
||||
*
|
||||
* If the hash is empty (direct visit / already logged in) the route re-runs the
|
||||
* portal handshake; the cool-down guard prevents redirect storms.
|
||||
*/
|
||||
export const Route = createFileRoute('/auth/callback')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
error: typeof search.error === 'string' ? search.error : undefined,
|
||||
}),
|
||||
beforeLoad: async ({ search }) => {
|
||||
await ensureAuthConfig()
|
||||
|
||||
if (search.error === 'sso_loop') return
|
||||
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const { accessToken } = parseHashToken(window.location.hash)
|
||||
if (accessToken) {
|
||||
setPortalToken(accessToken)
|
||||
clearPortalHandoffFlag()
|
||||
const claims = getClaims()
|
||||
if (!claims) {
|
||||
clearPortalToken()
|
||||
window.location.assign(authPortalUrl())
|
||||
await new Promise(() => {})
|
||||
return
|
||||
}
|
||||
throw redirect({ to: firstAllowedPath() as '/dashboard' })
|
||||
}
|
||||
|
||||
if (getPortalToken() && getClaims()) {
|
||||
clearPortalHandoffFlag()
|
||||
throw redirect({ to: firstAllowedPath() as '/dashboard' })
|
||||
}
|
||||
|
||||
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||
if (!ok) {
|
||||
throw redirect({
|
||||
to: '/auth/callback',
|
||||
search: { error: 'sso_loop' },
|
||||
})
|
||||
}
|
||||
await new Promise(() => {})
|
||||
},
|
||||
component: AuthCallbackPage,
|
||||
})
|
||||
|
||||
function AuthCallbackPage() {
|
||||
const { error } = Route.useSearch()
|
||||
if (error === 'sso_loop') {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<h1 className="text-lg font-semibold">Сессия не принята</h1>
|
||||
<p className="text-muted-foreground max-w-md text-sm">
|
||||
Повторный вход через auth-portal остановлен (защита от цикла редиректов).
|
||||
Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.
|
||||
Войдите заново на portal, затем откройте EvoBGP.
|
||||
</p>
|
||||
<a className="text-primary text-sm underline" href={authPortalUrl()}>
|
||||
Открыть Auth Portal
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -151,6 +151,37 @@ export type BgpCommunityCreate = {
|
||||
export type BgpCommunityPatch = Partial<BgpCommunityCreate>
|
||||
export type CommunitiesResponse = Page<BgpCommunity>
|
||||
|
||||
// ---- Lookup (GET /v1/lookup) ----
|
||||
/** @see https://reui.io/preview/base/stats-12 — KPI summary on /lookup */
|
||||
export type LookupQueryKind = 'ip' | 'domain'
|
||||
export type LookupLayer = 'entry' | 'snapshot'
|
||||
export type LookupMatchKind = 'ip_range' | 'domain' | 'prefix'
|
||||
|
||||
export type LookupMatch = {
|
||||
layer: LookupLayer
|
||||
module_id: string
|
||||
module_name: string
|
||||
module_type: ModuleType
|
||||
match_kind: LookupMatchKind
|
||||
matched_value: string
|
||||
entry_id?: string
|
||||
source?: string
|
||||
community_id?: string | null
|
||||
community?: string
|
||||
community_title?: string
|
||||
resolved_ip?: string
|
||||
}
|
||||
|
||||
export type LookupResponse = {
|
||||
query: string
|
||||
query_kind: LookupQueryKind
|
||||
normalized: string
|
||||
matched: boolean
|
||||
match_count: number
|
||||
matches: LookupMatch[]
|
||||
resolved_ips?: string[]
|
||||
}
|
||||
|
||||
// ---- Peers ----
|
||||
export type PeerSessionOnSpeaker = {
|
||||
speaker_id: string
|
||||
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_SWITCHER?: string
|
||||
/** '1' | 'true' → require auth-portal JWT; иначе — локальный API-токен. */
|
||||
readonly VITE_AUTH_ENABLED?: string
|
||||
/** URL auth-portal (SSO). Пример: http://192.168.100.67:5175 или https://auth.shnt.top. */
|
||||
readonly VITE_AUTH_PORTAL_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -37,6 +37,11 @@ func main() {
|
||||
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
|
||||
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
|
||||
RuntimeLogsPolicyTenant: cfg.RuntimeLogsPolicyTenant,
|
||||
JWTSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_JWT_SECRET"), os.Getenv("AUTH_JWT_SECRET")),
|
||||
AuthIssuer: firstNonEmpty(os.Getenv("EVOBGP_AUTH_ISSUER"), os.Getenv("AUTH_ISSUER")),
|
||||
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
|
||||
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
|
||||
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
|
||||
}
|
||||
srv, err := httpapi.New(opts)
|
||||
if err != nil {
|
||||
@@ -86,6 +91,31 @@ func main() {
|
||||
log.Printf("%s stopped", platform.ServiceName("evobgp-all"))
|
||||
}
|
||||
|
||||
func firstNonEmpty(candidates ...string) string {
|
||||
for _, c := range candidates {
|
||||
if v := strings.TrimSpace(c); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func boolFromEnv(keys ...string) bool {
|
||||
for _, k := range keys {
|
||||
v := strings.TrimSpace(os.Getenv(k))
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(v) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func startBirdMetricsPoller(ctx context.Context) {
|
||||
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
||||
if sock == "" {
|
||||
|
||||
@@ -32,6 +32,11 @@ func main() {
|
||||
SeedDemo: seedDemo,
|
||||
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
|
||||
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
|
||||
JWTSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_JWT_SECRET"), os.Getenv("AUTH_JWT_SECRET")),
|
||||
AuthIssuer: firstNonEmpty(os.Getenv("EVOBGP_AUTH_ISSUER"), os.Getenv("AUTH_ISSUER")),
|
||||
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
|
||||
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
|
||||
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
|
||||
}
|
||||
srv, err := httpapi.New(opts)
|
||||
if err != nil {
|
||||
@@ -78,6 +83,31 @@ func main() {
|
||||
log.Printf("%s stopped", platform.ServiceName("evobgp-api"))
|
||||
}
|
||||
|
||||
func firstNonEmpty(candidates ...string) string {
|
||||
for _, c := range candidates {
|
||||
if v := strings.TrimSpace(c); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func boolFromEnv(keys ...string) bool {
|
||||
for _, k := range keys {
|
||||
v := strings.TrimSpace(os.Getenv(k))
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(v) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func startBirdMetricsPoller(ctx context.Context) {
|
||||
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
||||
if sock == "" {
|
||||
|
||||
@@ -15,3 +15,10 @@ WEBUI_DOMAIN=bgp.example.com
|
||||
WEBUI_IP_WHITELIST=203.0.113.10/32
|
||||
LETSENCRYPT_EMAIL=[email protected]
|
||||
CF_DNS_API_TOKEN=
|
||||
|
||||
# Auth-portal SSO → контейнер evobgp-all (не VITE_* — они только для build web)
|
||||
AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
EVOBGP_PORTAL_TENANT_ID=
|
||||
|
||||
@@ -27,3 +27,16 @@ AUTO_UPDATE_INTERVAL_SEC=300
|
||||
AUTO_UPDATE_SERVICES=evobgp-all,evobgp-web
|
||||
# Защищенные сервисы, которые updater никогда не перезапускает
|
||||
AUTO_UPDATE_PROTECTED_SERVICES=bird2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-portal SSO (прокидывается в контейнер evobgp-all)
|
||||
# VITE_* в runtime .env НЕ влияют на уже собранный web-образ —
|
||||
# UI читает GET /v1/auth/config с API (AUTH_REQUIRED / AUTH_PORTAL_URL).
|
||||
# ---------------------------------------------------------------------------
|
||||
AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
# UUID tenant из БД (обязателен для JWT). При EVOBGP_SEED_DEMO=1 смотрите лог
|
||||
# старта evobgp-all / SELECT id FROM tenant LIMIT 1;
|
||||
EVOBGP_PORTAL_TENANT_ID=
|
||||
|
||||
@@ -136,6 +136,11 @@ services:
|
||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||
EVOBGP_SERVICE: evobgp-all
|
||||
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-}
|
||||
EVOBGP_PORTAL_TENANT_ID: ${EVOBGP_PORTAL_TENANT_ID:-}
|
||||
EVOBGP_DEV_INSECURE: "1"
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
|
||||
@@ -138,6 +138,12 @@ services:
|
||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||
EVOBGP_SERVICE: evobgp-all
|
||||
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
|
||||
# Portal SSO (JWT) — см. docs/access.md / auth-portal integrate-evobgp.md
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-}
|
||||
EVOBGP_PORTAL_TENANT_ID: ${EVOBGP_PORTAL_TENANT_ID:-}
|
||||
# DEV ONLY — не для production (см. docs/access.md).
|
||||
EVOBGP_DEV_INSECURE: "1"
|
||||
volumes:
|
||||
|
||||
@@ -2,6 +2,40 @@
|
||||
|
||||
Как выдавать доступ к control plane API, веб-клиентам и репликам BIRD (`evobgp-node`). Секреты храните в менеджере секретов, переменных окружения оркестратора или зашифрованных файлах — не коммитьте реальные ключи в Git.
|
||||
|
||||
## Portal SSO (JWT)
|
||||
|
||||
Единый вход через **auth-portal** (app id `bgp`). См. [integrate-evobgp.md](https://git.shts.su/denozord/auth-portal/src/branch/main/docs/integrate-evobgp.md) в репозитории auth-portal.
|
||||
|
||||
| Переменная | Назначение |
|
||||
|------------|------------|
|
||||
| `AUTH_REQUIRED` / `EVOBGP_AUTH_REQUIRED` | Включить проверку portal JWT для UI |
|
||||
| `AUTH_JWT_SECRET` / `EVOBGP_AUTH_JWT_SECRET` | Тот же секрет, что `JWT_SECRET` портала (HS256) |
|
||||
| `AUTH_ISSUER` | Issuer JWT (как на портале) |
|
||||
| `AUTH_PORTAL_URL` | URL портала (также `GET /v1/auth/config`) |
|
||||
| `EVOBGP_PORTAL_TENANT_ID` | Fallback tenant для portal JWT, если в токене нет `bgp_tenant_id` / `tenants.bgp` |
|
||||
|
||||
Источник tenant (по приоритету):
|
||||
|
||||
1. JWT claim `tenants.bgp` или `bgp_tenant_id` (задаётся в auth-portal → **Админ → Приложения** → поле «EvoBGP tenant ID»)
|
||||
2. Env `EVOBGP_PORTAL_TENANT_ID`
|
||||
|
||||
Compose: переменные `AUTH_*` / `EVOBGP_PORTAL_TENANT_ID` должны быть в `environment:` сервиса **`evobgp-all`** (см. `deploy/compose/stack.microvps-full.yaml`). Просто положить их в `.env` без проброса в контейнер недостаточно.
|
||||
|
||||
`VITE_AUTH_*` в runtime `.env` **не** меняют уже собранный `evobgp-web` образ. UI берёт режим из `GET /v1/auth/config` (`required` ← `AUTH_REQUIRED`, `portal_url` ← `AUTH_PORTAL_URL`).
|
||||
|
||||
Проверка после рестарта:
|
||||
|
||||
```bash
|
||||
curl -sS https://bgp.shnt.top/v1/auth/config
|
||||
# {"required":true,"portal_url":"https://auth.shnt.top"}
|
||||
```
|
||||
|
||||
Права — строки `bgp:<section>:<action>` из каталога портала (dashboard, modules, lookup, network, …). Apply/rollback требуют `bgp:operations:admin`.
|
||||
|
||||
**Ownership:** modules, peers, firewall clients/rules с `created_by_user_id` видны создателю и portal `is_admin` (API keys — весь tenant).
|
||||
|
||||
UI: `VITE_AUTH_ENABLED`, `VITE_AUTH_PORTAL_URL`. App Switcher: `CURRENT_APP_ID=bgp`, конфиг с `GET {portal}/api/v1/app-switcher`.
|
||||
|
||||
## API-ключи (`EVOBGP_API_KEYS`)
|
||||
|
||||
Формат переменной окружения: список записей через **запятую** без пробелов внутри логики парсера (пробелы вокруг записей допускаются при обрезке). Каждая запись:
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
- `POST /v1/modules`, `PATCH /v1/modules/{module_id}`, `DELETE /v1/modules/{module_id}`
|
||||
- `GET|POST|PATCH|DELETE` для `.../cdn-sources`, `.../as-entries`, `.../domain-entries`, `.../ip-range-entries`
|
||||
- `POST /v1/modules/{module_id}/refresh`
|
||||
- `GET /v1/router-lists/catalog` — агрегированный каталог модулей/entries/communities
|
||||
|
||||
### Lookup
|
||||
|
||||
- `GET /v1/lookup?q=` — быстрая проверка IP или FQDN в списках (viewer+).
|
||||
- Слой `entry`: `IP_RANGES` (`CIDR.Contains`) / `DOMAINS` (нормализованный FQDN).
|
||||
- Слой `snapshot`: материализованные `module_prefix_snapshot` (для IP — Contains по всем модулям; для домена — `source=domain` у matched DOMAINS-модулей).
|
||||
- В каждом матче — community (`community_id` / значение / title).
|
||||
- Live DoH не выполняется. Контракт: OpenAPI `lookupMembership`.
|
||||
|
||||
### DoH profiles
|
||||
|
||||
|
||||
+158
-2
@@ -11,9 +11,15 @@ info:
|
||||
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
|
||||
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
|
||||
|
||||
**Роли** (матрица доступа): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
|
||||
**Аутентификация (dual):**
|
||||
- **API key** — `Authorization: Bearer <token>` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`).
|
||||
- **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:<section>:<action>`; tenant из `tenants.bgp` / `bgp_tenant_id` или fallback `EVOBGP_PORTAL_TENANT_ID`.
|
||||
Публично: `GET /v1/auth/config` → `{ required, portal_url }`.
|
||||
|
||||
Заголовок `X-Tenant-Id` допускается только для супер-ролей (явный tenant); иначе tenant берётся из API-ключа.
|
||||
**Роли API key** (матрица): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
|
||||
JWT permissions мапятся на ту же лестницу (`:read`→viewer, `:write`→editor, `:admin`→operator).
|
||||
|
||||
Заголовок `X-Tenant-Id` допускается только для супер-ролей (явный tenant); иначе tenant берётся из API-ключа / portal tenant env.
|
||||
license:
|
||||
name: Proprietary
|
||||
identifier: LicenseRef-Proprietary
|
||||
@@ -27,6 +33,8 @@ tags:
|
||||
description: Liveness, readiness и метаданные сборки. Обычно без чувствительных данных; доступ может быть шире.
|
||||
- name: Modules
|
||||
description: Экземпляры модулей префиксов (AS, CDN, домены, статические IP-диапазоны) и вложенные записи. Чтение - viewer+; изменение - editor+.
|
||||
- name: Lookup
|
||||
description: Быстрая проверка membership IP/FQDN в списках (entries + module prefix snapshots) и community. Чтение - viewer+.
|
||||
- name: DoH profiles
|
||||
description: Профили DNS-over-HTTPS для модулей типа домены. Секрет в ответах не возвращается.
|
||||
- name: Communities
|
||||
@@ -221,6 +229,12 @@ components:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
BadRequest:
|
||||
description: Некорректный запрос (пустой или невалидный параметр).
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
Forbidden:
|
||||
description: Недостаточно прав для операции.
|
||||
content:
|
||||
@@ -751,6 +765,100 @@ components:
|
||||
description: Человекочитаемое название для UI и фильтров.
|
||||
additionalProperties: true
|
||||
|
||||
LookupQueryKind:
|
||||
type: string
|
||||
enum: [ip, domain]
|
||||
description: Определённый тип запроса после нормализации.
|
||||
|
||||
LookupLayer:
|
||||
type: string
|
||||
enum: [entry, snapshot]
|
||||
description: |
|
||||
`entry` — сырые IP_RANGES / DOMAINS entries;
|
||||
`snapshot` — материализованные префиксы `module_prefix_snapshot`.
|
||||
|
||||
LookupMatchKind:
|
||||
type: string
|
||||
enum: [ip_range, domain, prefix]
|
||||
description: Вид совпадения (entry CIDR, entry FQDN или snapshot prefix).
|
||||
|
||||
LookupMatch:
|
||||
type: object
|
||||
required:
|
||||
- layer
|
||||
- module_id
|
||||
- module_name
|
||||
- module_type
|
||||
- match_kind
|
||||
- matched_value
|
||||
properties:
|
||||
layer:
|
||||
$ref: "#/components/schemas/LookupLayer"
|
||||
module_id:
|
||||
$ref: "#/components/schemas/ResourceId"
|
||||
module_name:
|
||||
type: string
|
||||
module_type:
|
||||
$ref: "#/components/schemas/ModuleType"
|
||||
match_kind:
|
||||
$ref: "#/components/schemas/LookupMatchKind"
|
||||
matched_value:
|
||||
type: string
|
||||
description: CIDR, FQDN или prefix, с которым совпал запрос.
|
||||
entry_id:
|
||||
type: string
|
||||
description: ID entry (только для layer=entry).
|
||||
source:
|
||||
type: string
|
||||
description: Источник строки snapshot (ip_range, domain, as, cdn, …).
|
||||
community_id:
|
||||
type: ["string", "null"]
|
||||
community:
|
||||
type: string
|
||||
description: Техническое значение BGP community.
|
||||
community_title:
|
||||
type: string
|
||||
description: Человекочитаемое название community.
|
||||
resolved_ip:
|
||||
type: string
|
||||
description: |
|
||||
IP, полученный DNS-resolve domain-запроса, из-за которого появился этот матч.
|
||||
Пусто для прямого IP-запроса и для FQDN entry/snapshot без resolve.
|
||||
|
||||
LookupResponse:
|
||||
type: object
|
||||
required:
|
||||
- query
|
||||
- query_kind
|
||||
- normalized
|
||||
- matched
|
||||
- match_count
|
||||
- matches
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
description: Исходная строка запроса.
|
||||
query_kind:
|
||||
$ref: "#/components/schemas/LookupQueryKind"
|
||||
normalized:
|
||||
type: string
|
||||
description: Нормализованный IP или FQDN.
|
||||
matched:
|
||||
type: boolean
|
||||
description: true, если есть хотя бы одно совпадение.
|
||||
match_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
matches:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/LookupMatch"
|
||||
resolved_ips:
|
||||
type: array
|
||||
description: IP-адреса после live DNS resolve (только для query_kind=domain; A/AAAA).
|
||||
items:
|
||||
type: string
|
||||
|
||||
BgpPeer:
|
||||
type: object
|
||||
required:
|
||||
@@ -1455,6 +1563,10 @@ components:
|
||||
description: UTC cron для автоочистки (по умолчанию `0 */6 * * *`).
|
||||
runtime_logs_auto_mode:
|
||||
$ref: "#/components/schemas/RuntimeLogCleanupMode"
|
||||
ui_show_quick_actions:
|
||||
type: boolean
|
||||
description: Показывать блок «Быстрые действия» на дашборде (UI preference).
|
||||
default: true
|
||||
additionalProperties: true
|
||||
|
||||
RevisionDiff:
|
||||
@@ -1776,6 +1888,50 @@ paths:
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/lookup:
|
||||
get:
|
||||
tags: [Lookup]
|
||||
summary: Проверка IP или домена в списках
|
||||
description: |
|
||||
Быстрая membership-проверка по tenant:
|
||||
|
||||
- **IP** — слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot`
|
||||
(все module prefix snapshots, `Prefix.Contains`);
|
||||
- **Domain** — слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot`
|
||||
(префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть);
|
||||
затем **live DNS resolve** (A/AAAA через системный резолвер) и проверка
|
||||
каждого полученного IP так же, как для IP-запроса (ranges + все snapshots).
|
||||
|
||||
Community на матче: `entry.community_id || module.default_community_id` (entry)
|
||||
или `PrefixRow.community_id` (snapshot), с join к справочнику communities.
|
||||
|
||||
Поля `resolved_ips` / `resolved_ip` заполняются только для domain-запросов
|
||||
(после успешного DNS). Ошибка DNS не даёт 5xx: FQDN-слой всё равно возвращается.
|
||||
operationId: lookupMembership
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TenantId"
|
||||
- name: q
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 253
|
||||
description: IP-адрес или FQDN для проверки.
|
||||
responses:
|
||||
"200":
|
||||
description: Результат проверки (в т.ч. matched=false при отсутствии совпадений).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/LookupResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/router-lists/catalog:
|
||||
get:
|
||||
tags: [Modules]
|
||||
|
||||
@@ -18,25 +18,63 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad
|
||||
| Зона | Block | Preview |
|
||||
|------|-------|---------|
|
||||
| Shell | `app-shell-12` (+ cmdk/monitor где нужно) | https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7 |
|
||||
| KPI | `stats-12` (primary); `card-35` compact strip | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-35 |
|
||||
| KPI | horizontal compact hybrid (icon left + label/Badge + value ± variant; EvoBGP visual) | https://reui.io/preview/base/stats-12 |
|
||||
| Dashboard | `dashboard-1` | https://reui.io/preview/base/dashboard-1 |
|
||||
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||
| Settings | `settings-16` + SettingRow (`settings-7`) | https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-7 |
|
||||
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
|
||||
| Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 |
|
||||
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
|
||||
| Lookup | `/lookup` — Frame form + `KpiStatGrid` + DataGrid | https://reui.io/preview/base/form-7 · https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/empty-state-2 |
|
||||
|
||||
## Kit API (`reui-kit/`)
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
|
||||
| `KpiStatGrid` | stats-12 KPI tiles |
|
||||
| `KpiStatGrid` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
|
||||
| `QuickActionGrid` | KPI-like quick action tiles under KPI (gated by `ui_show_quick_actions`) |
|
||||
| `OpsDashboard` | KPI + charts + attention queue |
|
||||
| `SettingsShell` | settings nav + Outlet |
|
||||
| `DetailPanel` | detail Frame sections |
|
||||
| `filter-utils` | apply/clear ReUI Filters |
|
||||
|
||||
## Dashboard layout
|
||||
|
||||
| App | Section order |
|
||||
|-----|---------------|
|
||||
| EvoBGP / CFDM | KPI → **QuickActionGrid** → charts / rest |
|
||||
| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV |
|
||||
|
||||
Gating: KV `ui_show_quick_actions` in `global_settings` via `PATCH /v1/settings` (default `true`).
|
||||
|
||||
## Shared App Shell chrome
|
||||
|
||||
Эталон: **EvoBGP** production [`apps/web/src/components/layout/app-shell.tsx`](../apps/web/src/components/layout/app-shell.tsx) + ReUI [app-shell-12](https://reui.io/preview/base/app-shell-12).
|
||||
|
||||
При переключении между vps-tracker / CFDM / EvoBGP меняются **только** sidebar nav labels/hrefs и `main` content. Разметка, ширина, фон и hover chrome идентичны.
|
||||
|
||||
| Токен / зона | Значение |
|
||||
|--------------|----------|
|
||||
| `SIDEBAR_WIDTH` / `--sidebar-width` | `240px` (в `packages/ui` sidebar + Provider style) |
|
||||
| Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent` из `globals.css` — **без** AppShell `color-mix` override |
|
||||
| Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` |
|
||||
| Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
|
||||
| Header right | **AppsMenu** → **SystemMonitorPopover** → **ModeToggle** (без Search в chrome) |
|
||||
| Sidebar | AppSwitcher → groups (`SidebarGroupContent`) → icons `size-4` → **пустой** `SidebarFooter` |
|
||||
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
|
||||
| Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) |
|
||||
|
||||
Запрещено в chrome: `SidebarRail`, `NavUser` footer, sync-row footer, Search/Ctrl+K pill в header, issues Badge в header, muted/hover cascade на right-cluster, Provider `color-mix` для `--sidebar*`.
|
||||
|
||||
App Switcher: source of truth — auth-portal `GET /api/v1/app-switcher`. Id: `bgp`. Admin: portal `/admin/apps`.
|
||||
|
||||
QuickActionGrid icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid `bg-primary` fills. Preview: [stats-12](https://reui.io/preview/base/stats-12).
|
||||
|
||||
## System monitor
|
||||
|
||||
`SystemMonitorPopover` in app-shell header next to `ModeToggle` (после AppsMenu). Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7
|
||||
|
||||
## MCP workflow
|
||||
|
||||
1. MCP `user-reui` — `search` / `get_block` / `get_component` with `surface: "frame"`
|
||||
@@ -49,7 +87,7 @@ Primitives: MCP `plugin-shadcn-shadcn` + `@evobgp/ui`.
|
||||
|
||||
## Spacing
|
||||
|
||||
- Main: `gap-4 md:gap-6`, `px-4 md:px-6`
|
||||
- AppShell main: `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` (shared chrome)
|
||||
- No `space-y-*` / `space-x-*` — use `flex` + `gap-*`
|
||||
- Max 1 primary CTA per screen
|
||||
- Semantic tokens only (`variant="success"|"info"|"warning"`) — no raw `bg-emerald-*`
|
||||
|
||||
@@ -14,6 +14,7 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
|
||||
@@ -7,6 +7,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
|
||||
+147
-7
@@ -6,18 +6,32 @@ import (
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/authkey"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const authCtxKey ctxKey = 1
|
||||
|
||||
// Auth kinds distinguish API key sessions from portal JWT sessions.
|
||||
const (
|
||||
AuthKindAPIKey = "apikey"
|
||||
AuthKindJWT = "jwt"
|
||||
)
|
||||
|
||||
// Auth holds resolved API identity for a request.
|
||||
type Auth struct {
|
||||
TenantID string
|
||||
Role string // viewer, editor, operator, node
|
||||
Role string // viewer, editor, operator, node, firewall (apikeys only)
|
||||
Token string
|
||||
APIKeyID string // non-empty for DB-managed keys
|
||||
// Portal / dual-auth fields (empty for API keys unless noted).
|
||||
Kind string // "apikey" | "jwt"
|
||||
UserID string // JWT sub
|
||||
Email string // JWT email claim
|
||||
Permissions []string // JWT permissions claim (bgp:*)
|
||||
IsAdmin bool // JWT is_admin claim
|
||||
}
|
||||
|
||||
func authFromContext(ctx context.Context) (Auth, bool) {
|
||||
@@ -56,6 +70,23 @@ func parseAPIKeysSpec(spec string) []apiKeyRecord {
|
||||
return out
|
||||
}
|
||||
|
||||
// looksLikeJWT reports whether raw is a compact JWS (three dot-separated segments, non-empty).
|
||||
func looksLikeJWT(raw string) bool {
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(raw, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
for _, p := range parts {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := r.Header.Get("Authorization")
|
||||
@@ -65,6 +96,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
|
||||
if looksLikeJWT(raw) && strings.TrimSpace(s.jwtSecret) != "" {
|
||||
a, status, msg, ok := s.resolveJWT(raw)
|
||||
if !ok {
|
||||
writeProblem(w, status, http.StatusText(status), msg)
|
||||
return
|
||||
}
|
||||
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
a, ok := s.resolveAuth(raw)
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
|
||||
@@ -79,10 +120,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
func authFromKeyRecord(raw string, rec apiKeyRecord) Auth {
|
||||
return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID}
|
||||
return Auth{
|
||||
Kind: AuthKindAPIKey,
|
||||
TenantID: rec.tenantID,
|
||||
Role: rec.role,
|
||||
Token: raw,
|
||||
APIKeyID: rec.keyID,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAuth maps a bearer token to tenant identity.
|
||||
// resolveAuth maps a bearer token to tenant identity (API key path).
|
||||
// For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed
|
||||
// is available; env/DB mapping is used only when demo tenant is absent.
|
||||
func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
||||
@@ -99,23 +146,115 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
||||
if !ok {
|
||||
if s.firewallResolver != nil {
|
||||
if fw, ok := s.firewallResolver.Lookup(raw); ok {
|
||||
return Auth{TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
|
||||
return Auth{Kind: AuthKindAPIKey, TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
|
||||
}
|
||||
}
|
||||
if client, err := s.store.LookupFirewallClientByTokenHash(authkey.HashToken(raw)); err == nil {
|
||||
return Auth{TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
|
||||
return Auth{Kind: AuthKindAPIKey, TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
|
||||
}
|
||||
return Auth{}, false
|
||||
}
|
||||
return authFromKeyRecord(raw, rec), true
|
||||
}
|
||||
|
||||
// resolveJWT parses and validates a portal HS256 token, returning an Auth on success.
|
||||
// Returns (auth, status, detail, ok). status/detail are used when ok=false.
|
||||
func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
||||
tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(s.jwtSecret), nil
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
|
||||
if err != nil || tok == nil || !tok.Valid {
|
||||
return Auth{}, http.StatusUnauthorized, "invalid jwt", false
|
||||
}
|
||||
claims, ok := tok.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return Auth{}, http.StatusUnauthorized, "invalid jwt claims", false
|
||||
}
|
||||
if iss := strings.TrimSpace(s.authIssuer); iss != "" {
|
||||
got, _ := claims["iss"].(string)
|
||||
if strings.TrimSpace(got) != iss {
|
||||
return Auth{}, http.StatusUnauthorized, "jwt issuer mismatch", false
|
||||
}
|
||||
}
|
||||
apps := coerceStringSlice(claims["apps"])
|
||||
if !containsFold(apps, "bgp") {
|
||||
return Auth{}, http.StatusForbidden, "jwt does not grant access to bgp app", false
|
||||
}
|
||||
sub, _ := claims["sub"].(string)
|
||||
if strings.TrimSpace(sub) == "" {
|
||||
return Auth{}, http.StatusUnauthorized, "jwt missing sub", false
|
||||
}
|
||||
tenantID := tenantIDFromClaims(claims)
|
||||
if tenantID == "" {
|
||||
tenantID = strings.TrimSpace(s.portalTenantID)
|
||||
}
|
||||
if tenantID == "" {
|
||||
return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (set bgp tenant in auth-portal App Switcher or EVOBGP_PORTAL_TENANT_ID)", false
|
||||
}
|
||||
email, _ := claims["email"].(string)
|
||||
perms := coerceStringSlice(claims["permissions"])
|
||||
isAdmin, _ := claims["is_admin"].(bool)
|
||||
return Auth{
|
||||
Kind: AuthKindJWT,
|
||||
TenantID: tenantID,
|
||||
UserID: strings.TrimSpace(sub),
|
||||
Email: strings.TrimSpace(email),
|
||||
Permissions: perms,
|
||||
IsAdmin: isAdmin,
|
||||
Token: raw,
|
||||
}, 0, "", true
|
||||
}
|
||||
|
||||
// tenantIDFromClaims prefers tenants.bgp, then bgp_tenant_id.
|
||||
func tenantIDFromClaims(claims jwt.MapClaims) string {
|
||||
if m, ok := claims["tenants"].(map[string]any); ok {
|
||||
if v, ok := m["bgp"].(string); ok {
|
||||
if tid := strings.TrimSpace(v); tid != "" {
|
||||
return tid
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := claims["bgp_tenant_id"].(string); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func coerceStringSlice(v any) []string {
|
||||
switch t := v.(type) {
|
||||
case []string:
|
||||
return t
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, x := range t {
|
||||
if s, ok := x.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func containsFold(items []string, needle string) bool {
|
||||
for _, x := range items {
|
||||
if strings.EqualFold(strings.TrimSpace(x), needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) devAuth() (Auth, bool) {
|
||||
tid, _, _, _, _ := s.store.DemoIDs()
|
||||
if tid == "" {
|
||||
return Auth{}, false
|
||||
}
|
||||
return Auth{TenantID: tid, Role: "operator", Token: "dev"}, true
|
||||
return Auth{Kind: AuthKindAPIKey, TenantID: tid, Role: "operator", Token: "dev"}, true
|
||||
}
|
||||
|
||||
func roleLevel(role string) int {
|
||||
@@ -131,7 +270,8 @@ func roleLevel(role string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// requireAtLeast rejects node role and enforces viewer/editor/operator ladder.
|
||||
// requireAtLeast rejects node/firewall roles and enforces viewer/editor/operator ladder for API keys.
|
||||
// New code should call requirePerm which supports JWT permissions in addition to API-key roles.
|
||||
func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool {
|
||||
if strings.ToLower(a.Role) == "node" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
testJWTSecret = "test-secret-32-bytes-long-abcdef"
|
||||
testIssuer = "https://auth.test.local"
|
||||
)
|
||||
|
||||
func signTestJWT(t *testing.T, claims jwt.MapClaims) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
s, err := tok.SignedString([]byte(testJWTSecret))
|
||||
if err != nil {
|
||||
t.Fatalf("sign jwt: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func newJWTTestServer(t *testing.T) (*Server, string) {
|
||||
t.Helper()
|
||||
srv, err := New(Options{
|
||||
SeedDemo: true,
|
||||
BundleSeedHex: testBundleSeed,
|
||||
JWTSecret: testJWTSecret,
|
||||
AuthIssuer: testIssuer,
|
||||
AuthPortalURL: "https://portal.test.local",
|
||||
AuthRequired: true,
|
||||
PortalTenantID: "", // filled after DemoIDs
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
// Override tenant to match seed.
|
||||
srv.portalTenantID = tenant
|
||||
return srv, tenant
|
||||
}
|
||||
|
||||
func TestAuthJWTAcceptedWithBGPApp(t *testing.T) {
|
||||
srv, _ := newJWTTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
token := signTestJWT(t, jwt.MapClaims{
|
||||
"iss": testIssuer,
|
||||
"sub": "user-1",
|
||||
"email": "[email protected]",
|
||||
"apps": []string{"bgp"},
|
||||
"permissions": []string{"bgp:modules:read"},
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthJWTRejectedOnWrongIssuer(t *testing.T) {
|
||||
srv, _ := newJWTTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
token := signTestJWT(t, jwt.MapClaims{
|
||||
"iss": "https://other.example.com",
|
||||
"sub": "user-1",
|
||||
"apps": []string{"bgp"},
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthJWTRejectedWhenBGPAppMissing(t *testing.T) {
|
||||
srv, _ := newJWTTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
token := signTestJWT(t, jwt.MapClaims{
|
||||
"iss": testIssuer,
|
||||
"sub": "user-1",
|
||||
"apps": []string{"cfdm", "portal"},
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status=%d want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthJWTIsAdminBypassesPermissions(t *testing.T) {
|
||||
srv, _ := newJWTTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
token := signTestJWT(t, jwt.MapClaims{
|
||||
"iss": testIssuer,
|
||||
"sub": "admin-1",
|
||||
"apps": []string{"bgp"},
|
||||
"is_admin": true,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthJWTMissingPermissionRejected(t *testing.T) {
|
||||
srv, _ := newJWTTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
token := signTestJWT(t, jwt.MapClaims{
|
||||
"iss": testIssuer,
|
||||
"sub": "user-1",
|
||||
"apps": []string{"bgp"},
|
||||
"permissions": []string{"bgp:modules:read"},
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status=%d want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthJWTTenantFromClaimWithoutEnv(t *testing.T) {
|
||||
srv, err := New(Options{
|
||||
SeedDemo: true,
|
||||
BundleSeedHex: testBundleSeed,
|
||||
JWTSecret: testJWTSecret,
|
||||
AuthIssuer: testIssuer,
|
||||
AuthPortalURL: "https://portal.test.local",
|
||||
AuthRequired: true,
|
||||
// No PortalTenantID — must come from JWT claim.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
token := signTestJWT(t, jwt.MapClaims{
|
||||
"iss": testIssuer,
|
||||
"sub": "user-1",
|
||||
"apps": []string{"bgp"},
|
||||
"permissions": []string{"bgp:modules:read"},
|
||||
"bgp_tenant_id": tenant,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthJWTRejectedWhenTenantMissing(t *testing.T) {
|
||||
srv, err := New(Options{
|
||||
SeedDemo: true,
|
||||
BundleSeedHex: testBundleSeed,
|
||||
JWTSecret: testJWTSecret,
|
||||
AuthIssuer: testIssuer,
|
||||
AuthPortalURL: "https://portal.test.local",
|
||||
AuthRequired: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
token := signTestJWT(t, jwt.MapClaims{
|
||||
"iss": testIssuer,
|
||||
"sub": "user-1",
|
||||
"apps": []string{"bgp"},
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthConfigPublic(t *testing.T) {
|
||||
srv, _ := newJWTTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/config", nil)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPermissionSupersets(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
granted []string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"exact-read", []string{"bgp:modules:read"}, "bgp:modules:read", true},
|
||||
{"write-covers-read", []string{"bgp:modules:write"}, "bgp:modules:read", true},
|
||||
{"admin-covers-write", []string{"bgp:modules:admin"}, "bgp:modules:write", true},
|
||||
{"read-does-not-cover-write", []string{"bgp:modules:read"}, "bgp:modules:write", false},
|
||||
{"different-section", []string{"bgp:network:admin"}, "bgp:modules:read", false},
|
||||
{"empty-granted", nil, "bgp:modules:read", false},
|
||||
{"malformed-required", []string{"bgp:modules:admin"}, "bgp:modules", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := HasPermission(tc.granted, tc.want); got != tc.ok {
|
||||
t.Fatalf("HasPermission(%v, %q) = %v, want %v", tc.granted, tc.want, got, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Portal permission strings. Format: <app>:<section>:<level> (bgp:modules:read).
|
||||
// Superset order: admin ⊃ write ⊃ read for the same <app>:<section>.
|
||||
const (
|
||||
permLevelRead = "read"
|
||||
permLevelWrite = "write"
|
||||
permLevelAdmin = "admin"
|
||||
)
|
||||
|
||||
// permLevelRank returns 0 for unknown, 1 for read, 2 for write, 3 for admin.
|
||||
func permLevelRank(level string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case permLevelRead:
|
||||
return 1
|
||||
case permLevelWrite:
|
||||
return 2
|
||||
case permLevelAdmin:
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// splitPerm splits a permission string into (app, section, level).
|
||||
func splitPerm(perm string) (app, section, level string, ok bool) {
|
||||
parts := strings.Split(strings.TrimSpace(perm), ":")
|
||||
if len(parts) != 3 {
|
||||
return "", "", "", false
|
||||
}
|
||||
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), true
|
||||
}
|
||||
|
||||
// HasPermission reports whether the granted list satisfies required, applying the
|
||||
// admin ⊃ write ⊃ read superset within the same app+section.
|
||||
func HasPermission(granted []string, required string) bool {
|
||||
rApp, rSection, rLevel, ok := splitPerm(required)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
needRank := permLevelRank(rLevel)
|
||||
if needRank == 0 {
|
||||
return false
|
||||
}
|
||||
for _, g := range granted {
|
||||
gApp, gSection, gLevel, ok := splitPerm(g)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(gApp, rApp) || !strings.EqualFold(gSection, rSection) {
|
||||
continue
|
||||
}
|
||||
if permLevelRank(gLevel) >= needRank {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// permAPIKeyRoleFor maps a permission level to the API-key role required.
|
||||
func permAPIKeyRoleFor(perm string) string {
|
||||
_, _, level, ok := splitPerm(perm)
|
||||
if !ok {
|
||||
return "operator"
|
||||
}
|
||||
switch strings.ToLower(level) {
|
||||
case permLevelRead:
|
||||
return "viewer"
|
||||
case permLevelWrite:
|
||||
return "editor"
|
||||
case permLevelAdmin:
|
||||
return "operator"
|
||||
default:
|
||||
return "operator"
|
||||
}
|
||||
}
|
||||
|
||||
// requirePerm enforces a permission for a portal JWT or falls back to the API-key role ladder.
|
||||
// node/firewall roles are always rejected (they use requireNode / requireFirewall).
|
||||
func (s *Server) requirePerm(w http.ResponseWriter, a Auth, perm string) bool {
|
||||
switch strings.ToLower(a.Role) {
|
||||
case "node":
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
|
||||
return false
|
||||
case "firewall":
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "firewall role cannot access this resource")
|
||||
return false
|
||||
}
|
||||
if a.Kind == AuthKindJWT || len(a.Permissions) > 0 {
|
||||
if a.IsAdmin || HasPermission(a.Permissions, perm) {
|
||||
return true
|
||||
}
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "missing permission: "+perm)
|
||||
return false
|
||||
}
|
||||
need := permAPIKeyRoleFor(perm)
|
||||
if roleLevel(a.Role) < roleLevel(need) {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "insufficient role")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
+40
-41
@@ -35,6 +35,7 @@ func (s *Server) Handler() http.Handler {
|
||||
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
|
||||
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
||||
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
||||
s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic)
|
||||
s.mux.HandleFunc("POST /v1/firewall/enroll", s.handleFirewallEnrollPublic)
|
||||
s.mux.HandleFunc("GET /v1/firewall/install.sh", s.handleFirewallInstallScript)
|
||||
s.mux.HandleFunc("GET /v1/firewall/sync-script", s.handleFirewallSyncScript)
|
||||
@@ -54,6 +55,7 @@ func (s *Server) registerRoutes() {
|
||||
|
||||
func (s *Server) registerV1(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /modules", s.handleListModules)
|
||||
m.HandleFunc("GET /lookup", s.handleLookup)
|
||||
m.HandleFunc("GET /router-lists/catalog", s.handleRouterListsCatalog)
|
||||
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
|
||||
m.HandleFunc("GET /peers", s.handleListPeers)
|
||||
@@ -92,6 +94,15 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleAuthConfigPublic exposes portal-auth wiring so the UI can decide whether to redirect to the login portal.
|
||||
// Registered on the public mux (no auth middleware): safe to call without a bearer token.
|
||||
func (s *Server) handleAuthConfigPublic(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"required": s.authRequired,
|
||||
"portal_url": s.authPortalURL,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||
checks := map[string]string{"store": "ok", "jobs": "memory"}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
@@ -187,7 +198,7 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
typeFilter := strings.TrimSpace(r.URL.Query().Get("type"))
|
||||
@@ -203,23 +214,9 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
filtered := make([]*store.Module, 0)
|
||||
limit := parseListLimit(r)
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
if typeFilter == "" && enabledFilter == nil {
|
||||
page, next, more := s.store.ListModulesPage(a.TenantID, cursor, limit)
|
||||
for _, mod := range page {
|
||||
filtered = append(filtered, mod)
|
||||
}
|
||||
items := make([]map[string]any, 0, len(filtered))
|
||||
for _, mod := range filtered {
|
||||
items = append(items, moduleJSON(mod))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||
})
|
||||
return
|
||||
}
|
||||
for _, mod := range s.store.ListModules(a.TenantID) {
|
||||
all := s.store.ListModules(a.TenantID)
|
||||
all = store.FilterOwned(all, func(m *store.Module) string { return m.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
||||
for _, mod := range all {
|
||||
if typeFilter != "" && mod.Type != typeFilter {
|
||||
continue
|
||||
}
|
||||
@@ -244,7 +241,7 @@ func (s *Server) handleRouterListsCatalog(w http.ResponseWriter, r *http.Request
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:directories:read") {
|
||||
return
|
||||
}
|
||||
cat, err := reports.BuildRouterListsCatalog(s.store, a.TenantID)
|
||||
@@ -267,10 +264,14 @@ func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
|
||||
if err == nil && !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, mod.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if err == store.ErrNotFound || err == store.ErrTenantScope {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||
@@ -288,10 +289,11 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:network:read") {
|
||||
return
|
||||
}
|
||||
allPeers := s.store.ListPeers(a.TenantID)
|
||||
allPeers = store.FilterOwned(allPeers, func(p *store.BGPPeer) string { return p.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
||||
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||
fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
@@ -391,7 +393,7 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:network:read") {
|
||||
return
|
||||
}
|
||||
speakers := s.store.ListSpeakersForTenant(a.TenantID)
|
||||
@@ -423,7 +425,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "editor") {
|
||||
if !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
moduleID := r.PathValue("module_id")
|
||||
@@ -459,7 +461,7 @@ func (s *Server) handleTenantRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "editor") {
|
||||
if !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -513,7 +515,7 @@ func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
@@ -586,7 +588,7 @@ func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
rev, err := s.store.GetRevisionSummary(a.TenantID, r.PathValue("revision_id"))
|
||||
@@ -603,7 +605,7 @@ func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
|
||||
@@ -642,7 +644,7 @@ func (s *Server) handleRevisionDiagnosticLog(w http.ResponseWriter, r *http.Requ
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
revID := r.PathValue("revision_id")
|
||||
@@ -668,7 +670,7 @@ func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
d, err := s.store.RevisionDiff(a.TenantID, r.PathValue("revision_a"), r.PathValue("revision_b"))
|
||||
@@ -685,7 +687,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "operator") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||
return
|
||||
}
|
||||
revID := r.PathValue("revision_id")
|
||||
@@ -716,8 +718,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if strings.ToLower(a.Role) != "operator" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
|
||||
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -763,8 +764,7 @@ func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if strings.ToLower(a.Role) != "operator" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
|
||||
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||
return
|
||||
}
|
||||
spkID := r.PathValue("id")
|
||||
@@ -814,8 +814,7 @@ func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if strings.ToLower(a.Role) != "operator" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
|
||||
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||
return
|
||||
}
|
||||
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
@@ -838,7 +837,7 @@ func (s *Server) handleBirdStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:monitoring:read") {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
@@ -853,7 +852,7 @@ func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
@@ -876,7 +875,7 @@ func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
|
||||
@@ -893,7 +892,7 @@ func (s *Server) handleGetJobReport(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
|
||||
@@ -931,7 +930,7 @@ func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "editor") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||
return
|
||||
}
|
||||
j, err := s.jobs.RequestCancel(a.TenantID, r.PathValue("job_id"))
|
||||
|
||||
@@ -21,13 +21,22 @@ func (s *Server) registerAPIKeyRoutes(m *http.ServeMux) {
|
||||
|
||||
func (s *Server) handleAuthSession(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
resp := map[string]any{
|
||||
"tenant_id": a.TenantID,
|
||||
"role": a.Role,
|
||||
})
|
||||
"kind": a.Kind,
|
||||
}
|
||||
if a.Kind == AuthKindJWT {
|
||||
resp["user_id"] = a.UserID
|
||||
resp["email"] = a.Email
|
||||
resp["permissions"] = a.Permissions
|
||||
resp["is_admin"] = a.IsAdmin
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func apiKeyJSON(k *store.APIKey) map[string]any {
|
||||
@@ -59,7 +68,7 @@ func apiKeyJSON(k *store.APIKey) map[string]any {
|
||||
|
||||
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListAPIKeys(a.TenantID)
|
||||
@@ -74,7 +83,7 @@ func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||
return
|
||||
}
|
||||
k, err := s.store.GetAPIKey(a.TenantID, r.PathValue("id"))
|
||||
@@ -87,7 +96,7 @@ func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -127,7 +136,7 @@ func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||
return
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
@@ -183,7 +192,7 @@ func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||
return
|
||||
}
|
||||
if err := s.store.RevokeAPIKey(a.TenantID, r.PathValue("id")); err != nil {
|
||||
@@ -199,7 +208,7 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleRotateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||
return
|
||||
}
|
||||
rotated, err := s.store.RotateAPIKey(a.TenantID, r.PathValue("id"))
|
||||
|
||||
@@ -80,7 +80,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
||||
|
||||
func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -99,12 +99,16 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
mod, err := s.store.CreateModule(a.TenantID, &store.Module{
|
||||
newModule := &store.Module{
|
||||
Type: body.Type, Name: body.Name, Enabled: body.Enabled, Priority: body.Priority,
|
||||
RefreshIntervalSec: body.RefreshIntervalSec, CronExpr: body.CronExpr,
|
||||
DefaultCommunityID: body.DefaultCommunityID, DohProfileID: body.DohProfileID,
|
||||
DohProfileIDs: body.DohProfileIDs, DohResolverPolicy: body.DohResolverPolicy,
|
||||
})
|
||||
}
|
||||
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
|
||||
newModule.CreatedByUserID = a.UserID
|
||||
}
|
||||
mod, err := s.store.CreateModule(a.TenantID, newModule)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -114,7 +118,7 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
rawBody, err := io.ReadAll(r.Body)
|
||||
@@ -158,7 +162,14 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
|
||||
body.RefreshIntervalSec = &zero
|
||||
}
|
||||
}
|
||||
mod, err := s.store.UpdateModule(a.TenantID, r.PathValue("module_id"), &body)
|
||||
moduleID := r.PathValue("module_id")
|
||||
if existing, gerr := s.store.GetModule(a.TenantID, moduleID); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
mod, err := s.store.UpdateModule(a.TenantID, moduleID, &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -168,10 +179,17 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
if err := s.store.SoftDeleteModule(a.TenantID, r.PathValue("module_id")); err != nil {
|
||||
moduleID := r.PathValue("module_id")
|
||||
if existing, gerr := s.store.GetModule(a.TenantID, moduleID); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.SoftDeleteModule(a.TenantID, moduleID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -215,7 +233,7 @@ func writePostgresStoreErr(w http.ResponseWriter, err error) bool {
|
||||
|
||||
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListCDNSources(a.TenantID, r.PathValue("module_id"))
|
||||
@@ -248,7 +266,7 @@ func cdnSourceJSON(x *store.CDNSource) map[string]any {
|
||||
|
||||
func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -327,7 +345,7 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.CDNSource
|
||||
@@ -357,7 +375,7 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.CDNSourcePatch
|
||||
@@ -387,7 +405,7 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
@@ -401,7 +419,7 @@ func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListASEntries(a.TenantID, r.PathValue("module_id"))
|
||||
@@ -439,7 +457,7 @@ func asEntryJSON(x *store.ASEntry) map[string]any {
|
||||
|
||||
func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.ASEntry
|
||||
@@ -459,7 +477,7 @@ func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.ASEntryPatch
|
||||
@@ -479,7 +497,7 @@ func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
@@ -493,7 +511,7 @@ func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListDomainEntries(a.TenantID, r.PathValue("module_id"))
|
||||
@@ -516,7 +534,7 @@ func domainEntryJSON(x *store.DomainEntry) map[string]any {
|
||||
|
||||
func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.DomainEntry
|
||||
@@ -536,7 +554,7 @@ func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.DomainEntryPatch
|
||||
@@ -556,7 +574,7 @@ func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
@@ -570,7 +588,7 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListIPRangeEntries(a.TenantID, r.PathValue("module_id"))
|
||||
@@ -593,7 +611,7 @@ func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
|
||||
|
||||
func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.IPRangeEntry
|
||||
@@ -613,7 +631,7 @@ func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.IPRangePatch
|
||||
@@ -633,7 +651,7 @@ func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
@@ -647,7 +665,7 @@ func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleExportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
moduleID := r.PathValue("module_id")
|
||||
@@ -730,7 +748,7 @@ func (s *Server) handleExportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
|
||||
|
||||
func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
moduleID := r.PathValue("module_id")
|
||||
@@ -774,7 +792,7 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
|
||||
|
||||
func (s *Server) handleListDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListDohProfiles(a.TenantID)
|
||||
@@ -806,7 +824,7 @@ func dohJSON(x *store.DohProfile) map[string]any {
|
||||
|
||||
func (s *Server) handleGetDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
||||
return
|
||||
}
|
||||
x, err := s.store.GetDohProfile(a.TenantID, r.PathValue("id"))
|
||||
@@ -819,7 +837,7 @@ func (s *Server) handleGetDoh(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
var body store.DohProfile
|
||||
@@ -837,7 +855,7 @@ func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
var body store.DohProfilePatch
|
||||
@@ -855,7 +873,7 @@ func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteDohProfile(a.TenantID, r.PathValue("id")); err != nil {
|
||||
@@ -867,7 +885,7 @@ func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleListComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListCommunities(a.TenantID)
|
||||
@@ -892,7 +910,7 @@ func commJSON(x *store.Community) map[string]any {
|
||||
|
||||
func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
||||
return
|
||||
}
|
||||
x, err := s.store.GetCommunity(a.TenantID, r.PathValue("id"))
|
||||
@@ -905,7 +923,7 @@ func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
var body store.Community
|
||||
@@ -923,7 +941,7 @@ func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
var body store.CommunityPatch
|
||||
@@ -941,7 +959,7 @@ func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteCommunity(a.TenantID, r.PathValue("id")); err != nil {
|
||||
@@ -953,7 +971,7 @@ func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
var body store.BGPPeer
|
||||
@@ -962,6 +980,9 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
body.TenantID = a.TenantID
|
||||
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
|
||||
body.CreatedByUserID = a.UserID
|
||||
}
|
||||
x, err := s.store.CreatePeer(a.TenantID, &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
@@ -973,7 +994,7 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
|
||||
return
|
||||
}
|
||||
x, err := s.store.GetPeer(a.TenantID, r.PathValue("id"))
|
||||
@@ -981,12 +1002,16 @@ func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, x.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, peerJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
var body store.PeerPatch
|
||||
@@ -994,7 +1019,14 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.UpdatePeer(a.TenantID, r.PathValue("id"), &body)
|
||||
peerID := r.PathValue("id")
|
||||
if existing, gerr := s.store.GetPeer(a.TenantID, peerID); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
x, err := s.store.UpdatePeer(a.TenantID, peerID, &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -1005,10 +1037,17 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeletePeer(a.TenantID, r.PathValue("id")); err != nil {
|
||||
peerID := r.PathValue("id")
|
||||
if existing, gerr := s.store.GetPeer(a.TenantID, peerID); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.DeletePeer(a.TenantID, peerID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -1018,7 +1057,7 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
var body store.Speaker
|
||||
@@ -1044,7 +1083,7 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
|
||||
return
|
||||
}
|
||||
x, err := s.store.GetSpeaker(a.TenantID, r.PathValue("speaker_id"))
|
||||
@@ -1057,7 +1096,7 @@ func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
var body store.SpeakerPatch
|
||||
@@ -1075,7 +1114,7 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteSpeaker(a.TenantID, r.PathValue("speaker_id")); err != nil {
|
||||
@@ -1087,7 +1126,7 @@ func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
@@ -1111,7 +1150,7 @@ func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") {
|
||||
return
|
||||
}
|
||||
m, err := s.store.ListGlobalSettings(a.TenantID)
|
||||
@@ -1124,7 +1163,7 @@ func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") {
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
|
||||
@@ -43,7 +43,7 @@ func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
|
||||
|
||||
func (s *Server) handleFirewallInstallContext(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
seed := strings.TrimSpace(s.bundleSeedHex)
|
||||
@@ -187,7 +187,7 @@ func readFirewallScript(name string) ([]byte, error) {
|
||||
|
||||
func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
||||
return
|
||||
}
|
||||
items, err := s.store.ListFirewallClients(a.TenantID)
|
||||
@@ -195,12 +195,13 @@ func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Reques
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
items = store.FilterOwned(items, func(c *store.FirewallClient) string { return c.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetFirewallClient(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
@@ -209,15 +210,25 @@ func (s *Server) handleGetFirewallClient(w http.ResponseWriter, r *http.Request)
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
}
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, client.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, client)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
var patch store.FirewallClientPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
||||
@@ -233,10 +244,16 @@ func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
func (s *Server) handleApproveFirewallClient(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
client, err := s.store.ApproveFirewallClient(a.TenantID, id, a.APIKeyID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
@@ -249,10 +266,16 @@ func (s *Server) handleApproveFirewallClient(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
func (s *Server) handleRevokeFirewallClient(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.RevokeFirewallClient(a.TenantID, id); err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
@@ -264,10 +287,16 @@ func (s *Server) handleRevokeFirewallClient(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
func (s *Server) handleDeleteFirewallClient(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteFirewallClient(a.TenantID, id); err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
@@ -279,7 +308,7 @@ func (s *Server) handleDeleteFirewallClient(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
func (s *Server) handleListFirewallRules(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
||||
return
|
||||
}
|
||||
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
|
||||
@@ -297,12 +326,13 @@ func (s *Server) handleListFirewallRules(w http.ResponseWriter, r *http.Request)
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
items = store.FilterOwned(items, func(rule *store.FirewallRule) string { return rule.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -326,12 +356,16 @@ func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request
|
||||
cid := strings.TrimSpace(*body.ClientID)
|
||||
clientID = &cid
|
||||
}
|
||||
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, &store.FirewallRuleCreate{
|
||||
fwRule := &store.FirewallRuleCreate{
|
||||
Priority: body.Priority,
|
||||
Action: body.Action,
|
||||
CommunityID: body.CommunityID,
|
||||
Comment: body.Comment,
|
||||
})
|
||||
}
|
||||
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
|
||||
fwRule.CreatedByUserID = a.UserID
|
||||
}
|
||||
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, fwRule)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid rule")
|
||||
return
|
||||
@@ -342,10 +376,16 @@ func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request
|
||||
|
||||
func (s *Server) handlePatchFirewallRule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
var patch store.FirewallRulePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
||||
@@ -362,10 +402,16 @@ func (s *Server) handlePatchFirewallRule(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handleDeleteFirewallRule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteFirewallRule(a.TenantID, id); err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
||||
return
|
||||
@@ -376,7 +422,7 @@ func (s *Server) handleDeleteFirewallRule(w http.ResponseWriter, r *http.Request
|
||||
|
||||
func (s *Server) handleReorderFirewallRules(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -494,7 +540,7 @@ func (s *Server) handleFirewallHeartbeat(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handleFirewallClientPreview(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/lookup"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// handleLookup implements GET /v1/lookup?q= (operationId: lookupMembership).
|
||||
func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requirePerm(w, a, "bgp:lookup:read") {
|
||||
return
|
||||
}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if q == "" {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "query parameter q is required")
|
||||
return
|
||||
}
|
||||
res, err := lookup.Lookup(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")
|
||||
return
|
||||
}
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestLookupMembershipHTTP(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
|
||||
|
||||
comms, err := srv.Store().ListCommunities(tenant)
|
||||
if err != nil || len(comms) == 0 {
|
||||
t.Fatal("demo community")
|
||||
}
|
||||
cid := comms[0].ID
|
||||
if _, err := srv.Store().CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{
|
||||
Prefix: "198.51.100.0/24",
|
||||
CommunityID: &cid,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := srv.Store().SetModulePrefixSnapshot(tenant, modIP, "t", []store.PrefixRow{
|
||||
{Prefix: "198.51.100.0/24", CommunityID: &cid, Source: "ip_range"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/lookup?q="+url.QueryEscape("198.51.100.7"), nil)
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
var body struct {
|
||||
Matched bool `json:"matched"`
|
||||
MatchCount int `json:"match_count"`
|
||||
QueryKind string `json:"query_kind"`
|
||||
Matches []struct {
|
||||
Layer string `json:"layer"`
|
||||
} `json:"matches"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !body.Matched || body.QueryKind != "ip" || body.MatchCount < 2 {
|
||||
t.Fatalf("unexpected body: %+v", body)
|
||||
}
|
||||
|
||||
reqBad, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/lookup?q=", nil)
|
||||
reqBad.Header.Set("Authorization", "Bearer edkey")
|
||||
respBad, err := ts.Client().Do(reqBad)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = respBad.Body.Close() }()
|
||||
if respBad.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("empty q: status %d", respBad.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func maintenancePolicyJSON(p *store.MaintenancePolicy) map[string]any {
|
||||
|
||||
func (s *Server) handleListMaintenancePolicies(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
@@ -81,7 +81,7 @@ func (s *Server) handleListMaintenancePolicies(w http.ResponseWriter, r *http.Re
|
||||
|
||||
func (s *Server) handleGetMaintenancePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
p, err := s.store.GetMaintenancePolicy(r.PathValue("id"))
|
||||
@@ -163,7 +163,7 @@ func (s *Server) handleDeleteMaintenancePolicy(w http.ResponseWriter, r *http.Re
|
||||
|
||||
func (s *Server) handleMaintenancePolicyHints(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
if s.maintStats == nil {
|
||||
@@ -185,7 +185,7 @@ func (s *Server) handleMaintenancePolicyHints(w http.ResponseWriter, r *http.Req
|
||||
|
||||
func (s *Server) handleListMaintenanceConfigAudit(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
|
||||
@@ -27,12 +27,10 @@ func (s *Server) registerPostgresMaintenanceRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /postgres/maintenance/logs", s.handlePostgresMaintenanceLogs)
|
||||
}
|
||||
|
||||
// requireOperatorStrict is a compatibility shim mapping the legacy "operator"
|
||||
// API-key role to the tenant-settings admin permission for JWT/API-key clients.
|
||||
func (s *Server) requireOperatorStrict(w http.ResponseWriter, a Auth) bool {
|
||||
if strings.ToLower(a.Role) != "operator" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return s.requirePerm(w, a, "bgp:tenant_settings:admin")
|
||||
}
|
||||
|
||||
func (s *Server) checkPgMaintRateLimit(tenantID, kind string) bool {
|
||||
@@ -201,7 +199,7 @@ func (s *Server) handlePostgresCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostgresMaintenanceLogs(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
|
||||
@@ -35,7 +35,7 @@ func parseLimitQuery(r *http.Request, def, max int) int {
|
||||
|
||||
func (s *Server) handlePostgresOverview(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
@@ -50,7 +50,7 @@ func (s *Server) handlePostgresOverview(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handlePostgresQueries(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
@@ -65,7 +65,7 @@ func (s *Server) handlePostgresQueries(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostgresLocks(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
@@ -80,7 +80,7 @@ func (s *Server) handlePostgresLocks(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostgresTables(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
@@ -95,7 +95,7 @@ func (s *Server) handlePostgresTables(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostgresRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
@@ -110,7 +110,7 @@ func (s *Server) handlePostgresRecommendations(w http.ResponseWriter, r *http.Re
|
||||
|
||||
func (s *Server) handleMonitoringCorrelation(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
window := 60
|
||||
|
||||
@@ -52,7 +52,7 @@ func (s *Server) resolveRevisionRetentionMinutesBody(r *http.Request, tenantID s
|
||||
|
||||
func (s *Server) handleRevisionPruneEstimate(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
minutes, valid := s.resolveRevisionRetentionMinutesQuery(r, a.TenantID)
|
||||
@@ -78,7 +78,7 @@ func (s *Server) handleRevisionPruneEstimate(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
func (s *Server) handleRevisionPrune(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||
return
|
||||
}
|
||||
minutes, valid := s.resolveRevisionRetentionMinutesBody(r, a.TenantID)
|
||||
|
||||
@@ -70,7 +70,7 @@ func runtimeLogCleanupAuditJSON(row *store.RuntimeLogCleanupAudit) map[string]an
|
||||
|
||||
func (s *Server) handleListRuntimeLogFiles(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requireRuntimeLogs(w) {
|
||||
return
|
||||
}
|
||||
items, err := s.runtimeLogs.ListFiles()
|
||||
@@ -87,7 +87,7 @@ func (s *Server) handleListRuntimeLogFiles(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
func (s *Server) handleGetRuntimeLogTail(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requireRuntimeLogs(w) {
|
||||
return
|
||||
}
|
||||
filename := r.PathValue("filename")
|
||||
@@ -111,7 +111,7 @@ func (s *Server) handleGetRuntimeLogTail(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handleDeleteRuntimeLogFile(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
|
||||
return
|
||||
}
|
||||
filename := r.PathValue("filename")
|
||||
@@ -157,7 +157,7 @@ func (s *Server) runtimeLogAutoPolicy(w http.ResponseWriter, r *http.Request, te
|
||||
|
||||
func (s *Server) handleRuntimeLogAutoEstimate(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
|
||||
return
|
||||
}
|
||||
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
|
||||
@@ -199,7 +199,7 @@ func (s *Server) handleRuntimeLogAutoEstimate(w http.ResponseWriter, r *http.Req
|
||||
|
||||
func (s *Server) handleRuntimeLogAutoRun(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
|
||||
return
|
||||
}
|
||||
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
|
||||
@@ -221,7 +221,7 @@ func (s *Server) handleRuntimeLogAutoRun(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handleListRuntimeLogCleanupAudit(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") {
|
||||
return
|
||||
}
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
|
||||
@@ -36,6 +36,13 @@ type Server struct {
|
||||
runtimeLogs *runtimelogs.Service
|
||||
runtimeLogsPolicyTenant string
|
||||
mux *http.ServeMux
|
||||
|
||||
// Portal / dual-auth (JWT) configuration.
|
||||
jwtSecret string
|
||||
authIssuer string
|
||||
authPortalURL string
|
||||
portalTenantID string
|
||||
authRequired bool
|
||||
}
|
||||
|
||||
// Options configures the API server.
|
||||
@@ -49,6 +56,13 @@ type Options struct {
|
||||
CORSAllowedOrigins string
|
||||
// RuntimeLogsPolicyTenant overrides tenant for auto-cleanup scheduler settings (optional).
|
||||
RuntimeLogsPolicyTenant string
|
||||
|
||||
// Portal / dual-auth (JWT) — leave empty to disable JWT path.
|
||||
JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret)
|
||||
AuthIssuer string // AUTH_ISSUER (expected iss claim; default https://auth.shnt.top)
|
||||
AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI)
|
||||
PortalTenantID string // fallback when JWT has no bgp_tenant_id / tenants.bgp
|
||||
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
|
||||
}
|
||||
|
||||
// New constructs Server and wiring for async jobs.
|
||||
@@ -104,6 +118,14 @@ func New(opts Options) (*Server, error) {
|
||||
cdnHTTP: NewCDNHTTPClient(),
|
||||
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
|
||||
runtimeLogsPolicyTenant: strings.TrimSpace(opts.RuntimeLogsPolicyTenant),
|
||||
jwtSecret: strings.TrimSpace(opts.JWTSecret),
|
||||
authIssuer: strings.TrimSpace(opts.AuthIssuer),
|
||||
authPortalURL: strings.TrimSpace(opts.AuthPortalURL),
|
||||
portalTenantID: strings.TrimSpace(opts.PortalTenantID),
|
||||
authRequired: opts.AuthRequired,
|
||||
}
|
||||
if s.authIssuer == "" {
|
||||
s.authIssuer = "https://auth.shnt.top"
|
||||
}
|
||||
s.mux = http.NewServeMux()
|
||||
s.registerRoutes()
|
||||
|
||||
@@ -71,7 +71,7 @@ func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any {
|
||||
|
||||
func (s *Server) handleBundleSigningPublicKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
// Package lookup implements dual-layer membership checks for IP addresses and FQDNs
|
||||
// against module entries and materialized prefix snapshots.
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// QueryKind is the normalized kind of a lookup query.
|
||||
type QueryKind string
|
||||
|
||||
const (
|
||||
KindIP QueryKind = "ip"
|
||||
KindDomain QueryKind = "domain"
|
||||
)
|
||||
|
||||
// Layer identifies which data source produced a match.
|
||||
type Layer string
|
||||
|
||||
const (
|
||||
LayerEntry Layer = "entry"
|
||||
LayerSnapshot Layer = "snapshot"
|
||||
)
|
||||
|
||||
// MatchKind is the concrete match type within a layer.
|
||||
type MatchKind string
|
||||
|
||||
const (
|
||||
MatchIPRange MatchKind = "ip_range"
|
||||
MatchDomain MatchKind = "domain"
|
||||
MatchPrefix MatchKind = "prefix"
|
||||
)
|
||||
|
||||
// Match is one membership hit (entry or snapshot) with resolved community fields.
|
||||
type Match struct {
|
||||
Layer Layer `json:"layer"`
|
||||
ModuleID string `json:"module_id"`
|
||||
ModuleName string `json:"module_name"`
|
||||
ModuleType string `json:"module_type"`
|
||||
MatchKind MatchKind `json:"match_kind"`
|
||||
MatchedValue string `json:"matched_value"`
|
||||
EntryID string `json:"entry_id,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
CommunityID *string `json:"community_id,omitempty"`
|
||||
Community string `json:"community,omitempty"`
|
||||
CommunityTitle string `json:"community_title,omitempty"`
|
||||
// ResolvedIP is set when the hit came from a DNS-resolved address of a domain query.
|
||||
ResolvedIP string `json:"resolved_ip,omitempty"`
|
||||
}
|
||||
|
||||
// Result is the full lookup response payload.
|
||||
type Result struct {
|
||||
Query string `json:"query"`
|
||||
QueryKind QueryKind `json:"query_kind"`
|
||||
Normalized string `json:"normalized"`
|
||||
Matched bool `json:"matched"`
|
||||
MatchCount int `json:"match_count"`
|
||||
Matches []Match `json:"matches"`
|
||||
ResolvedIPs []string `json:"resolved_ips,omitempty"`
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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)
|
||||
}
|
||||
|
||||
// LookupWithResolver is like Lookup but uses resolve for domain→IP (tests / alternate DNS).
|
||||
func LookupWithResolver(
|
||||
ctx context.Context,
|
||||
st store.Backend,
|
||||
tenantID, q string,
|
||||
resolve DomainResolver,
|
||||
) (*Result, error) {
|
||||
raw := strings.TrimSpace(q)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("%w: empty query", store.ErrInvalidInput)
|
||||
}
|
||||
|
||||
comms, err := st.ListCommunities(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commByID := make(map[string]*store.Community, len(comms))
|
||||
for _, c := range comms {
|
||||
if c != nil {
|
||||
commByID[c.ID] = c
|
||||
}
|
||||
}
|
||||
|
||||
out := &Result{
|
||||
Query: raw,
|
||||
Matches: make([]Match, 0),
|
||||
}
|
||||
|
||||
if addr, err := netip.ParseAddr(raw); err == nil {
|
||||
out.QueryKind = KindIP
|
||||
out.Normalized = addr.String()
|
||||
if err := lookupIP(st, tenantID, addr, out, commByID, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
fqdn, ok := normalizeFQDN(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: query must be an IP address or FQDN", store.ErrInvalidInput)
|
||||
}
|
||||
out.QueryKind = KindDomain
|
||||
out.Normalized = fqdn
|
||||
if err := lookupDomain(st, tenantID, fqdn, out, commByID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolve == nil {
|
||||
resolve = systemDNSResolver
|
||||
}
|
||||
if err := lookupResolvedIPs(ctx, st, tenantID, fqdn, out, commByID, resolve); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
out.MatchCount = len(out.Matches)
|
||||
out.Matched = out.MatchCount > 0
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func systemDNSResolver(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uniqAddrs(ips), nil
|
||||
}
|
||||
|
||||
func uniqAddrs(in []netip.Addr) []netip.Addr {
|
||||
seen := make(map[netip.Addr]struct{}, len(in))
|
||||
out := make([]netip.Addr, 0, len(in))
|
||||
for _, a := range in {
|
||||
a = a.Unmap()
|
||||
if _, ok := seen[a]; ok {
|
||||
continue
|
||||
}
|
||||
seen[a] = struct{}{}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func lookupResolvedIPs(
|
||||
ctx context.Context,
|
||||
st store.Backend,
|
||||
tenantID, fqdn string,
|
||||
out *Result,
|
||||
commByID map[string]*store.Community,
|
||||
resolve DomainResolver,
|
||||
) error {
|
||||
ips, err := resolve(ctx, fqdn)
|
||||
if err != nil {
|
||||
// DNS failure must not hide FQDN-layer matches already collected.
|
||||
return nil
|
||||
}
|
||||
out.ResolvedIPs = make([]string, 0, len(ips))
|
||||
for _, ip := range ips {
|
||||
out.ResolvedIPs = append(out.ResolvedIPs, ip.String())
|
||||
if err := lookupIP(st, tenantID, ip, out, commByID, ip.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupIP(
|
||||
st store.Backend,
|
||||
tenantID string,
|
||||
addr netip.Addr,
|
||||
out *Result,
|
||||
commByID map[string]*store.Community,
|
||||
resolvedIP string,
|
||||
) error {
|
||||
for _, mod := range st.ListModules(tenantID) {
|
||||
if mod == nil {
|
||||
continue
|
||||
}
|
||||
if mod.Type == "IP_RANGES" {
|
||||
entries, err := st.ListIPRangeEntries(tenantID, mod.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
pfx, err := netip.ParsePrefix(strings.TrimSpace(e.Prefix))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !pfx.Contains(addr) {
|
||||
continue
|
||||
}
|
||||
out.Matches = append(out.Matches, decorateMatch(Match{
|
||||
Layer: LayerEntry,
|
||||
ModuleID: mod.ID,
|
||||
ModuleName: mod.Name,
|
||||
ModuleType: mod.Type,
|
||||
MatchKind: MatchIPRange,
|
||||
MatchedValue: e.Prefix,
|
||||
EntryID: e.ID,
|
||||
CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID),
|
||||
ResolvedIP: resolvedIP,
|
||||
}, commByID))
|
||||
}
|
||||
}
|
||||
|
||||
snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mod.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || snap == nil {
|
||||
continue
|
||||
}
|
||||
for _, row := range snap.Prefixes {
|
||||
pfx, err := netip.ParsePrefix(strings.TrimSpace(row.Prefix))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !pfx.Contains(addr) {
|
||||
continue
|
||||
}
|
||||
out.Matches = append(out.Matches, decorateMatch(Match{
|
||||
Layer: LayerSnapshot,
|
||||
ModuleID: mod.ID,
|
||||
ModuleName: mod.Name,
|
||||
ModuleType: mod.Type,
|
||||
MatchKind: MatchPrefix,
|
||||
MatchedValue: row.Prefix,
|
||||
Source: row.Source,
|
||||
CommunityID: row.CommunityID,
|
||||
ResolvedIP: resolvedIP,
|
||||
}, commByID))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupDomain(st store.Backend, tenantID, fqdn string, out *Result, commByID map[string]*store.Community) error {
|
||||
matchedModuleIDs := make(map[string]*store.Module)
|
||||
|
||||
for _, mod := range st.ListModules(tenantID) {
|
||||
if mod == nil || mod.Type != "DOMAINS" {
|
||||
continue
|
||||
}
|
||||
entries, err := st.ListDomainEntries(tenantID, mod.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
norm, ok := normalizeFQDN(e.FQDN)
|
||||
if !ok || norm != fqdn {
|
||||
continue
|
||||
}
|
||||
matchedModuleIDs[mod.ID] = mod
|
||||
out.Matches = append(out.Matches, decorateMatch(Match{
|
||||
Layer: LayerEntry,
|
||||
ModuleID: mod.ID,
|
||||
ModuleName: mod.Name,
|
||||
ModuleType: mod.Type,
|
||||
MatchKind: MatchDomain,
|
||||
MatchedValue: e.FQDN,
|
||||
EntryID: e.ID,
|
||||
CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID),
|
||||
}, commByID))
|
||||
}
|
||||
}
|
||||
|
||||
for mid, mod := range matchedModuleIDs {
|
||||
snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || snap == nil {
|
||||
continue
|
||||
}
|
||||
for _, row := range snap.Prefixes {
|
||||
if !strings.EqualFold(strings.TrimSpace(row.Source), "domain") {
|
||||
continue
|
||||
}
|
||||
out.Matches = append(out.Matches, decorateMatch(Match{
|
||||
Layer: LayerSnapshot,
|
||||
ModuleID: mid,
|
||||
ModuleName: mod.Name,
|
||||
ModuleType: mod.Type,
|
||||
MatchKind: MatchPrefix,
|
||||
MatchedValue: row.Prefix,
|
||||
Source: row.Source,
|
||||
CommunityID: row.CommunityID,
|
||||
}, commByID))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveCommunityID(entryID, defaultID *string) *string {
|
||||
if entryID != nil && strings.TrimSpace(*entryID) != "" {
|
||||
return entryID
|
||||
}
|
||||
if defaultID != nil && strings.TrimSpace(*defaultID) != "" {
|
||||
return defaultID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decorateMatch(m Match, commByID map[string]*store.Community) Match {
|
||||
if m.CommunityID == nil {
|
||||
return m
|
||||
}
|
||||
c, ok := commByID[*m.CommunityID]
|
||||
if !ok || c == nil {
|
||||
return m
|
||||
}
|
||||
m.Community = c.Community
|
||||
m.CommunityTitle = c.Title
|
||||
return m
|
||||
}
|
||||
|
||||
// normalizeFQDN lowercases, trims trailing dots, and validates a simple hostname shape.
|
||||
func normalizeFQDN(s string) (string, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimSuffix(s, ".")
|
||||
s = strings.ToLower(s)
|
||||
if s == "" || len(s) > 253 {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsAny(s, " /\\\t\n") {
|
||||
return "", false
|
||||
}
|
||||
if _, err := netip.ParseAddr(s); err == nil {
|
||||
return "", false
|
||||
}
|
||||
labels := strings.Split(s, ".")
|
||||
if len(labels) < 2 {
|
||||
return "", false
|
||||
}
|
||||
for _, label := range labels {
|
||||
if label == "" || len(label) > 63 {
|
||||
return "", false
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return "", false
|
||||
}
|
||||
for _, r := range label {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
|
||||
continue
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestLookupIPEntryAndSnapshot(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, modIP, _, _ := m.DemoIDs()
|
||||
|
||||
cid := ""
|
||||
comms, err := m.ListCommunities(tenant)
|
||||
if err != nil || len(comms) == 0 {
|
||||
t.Fatal("expected demo community")
|
||||
}
|
||||
cid = comms[0].ID
|
||||
|
||||
def := cid
|
||||
if _, err := m.UpdateModule(tenant, modIP, &store.ModulePatch{DefaultCommunityID: &def}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entryComm := cid
|
||||
e, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{
|
||||
Prefix: "203.0.113.0/24",
|
||||
CommunityID: &entryComm,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := m.SetModulePrefixSnapshot(tenant, modIP, "hash1", []store.PrefixRow{
|
||||
{Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "ip_range"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := Lookup(context.Background(), m, tenant, "203.0.113.10")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.QueryKind != KindIP || res.Normalized != "203.0.113.10" {
|
||||
t.Fatalf("kind/normalized: %+v", res)
|
||||
}
|
||||
if !res.Matched || res.MatchCount < 2 {
|
||||
t.Fatalf("expected entry+snapshot matches, got %+v", res)
|
||||
}
|
||||
|
||||
var entryHit, snapHit bool
|
||||
for _, hit := range res.Matches {
|
||||
if hit.Layer == LayerEntry && hit.EntryID == e.ID {
|
||||
entryHit = true
|
||||
if hit.Community != "demo-comm" || hit.CommunityTitle != "Demo" {
|
||||
t.Fatalf("entry community: %+v", hit)
|
||||
}
|
||||
}
|
||||
if hit.Layer == LayerSnapshot && hit.MatchedValue == "203.0.113.0/24" {
|
||||
snapHit = true
|
||||
}
|
||||
}
|
||||
if !entryHit || !snapHit {
|
||||
t.Fatalf("missing layers entry=%v snap=%v matches=%+v", entryHit, snapHit, res.Matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupIPCommunityFallback(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, modIP, _, _ := m.DemoIDs()
|
||||
comms, _ := m.ListCommunities(tenant)
|
||||
cid := comms[0].ID
|
||||
if _, err := m.UpdateModule(tenant, modIP, &store.ModulePatch{DefaultCommunityID: &cid}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{Prefix: "10.0.0.0/8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := Lookup(context.Background(), m, tenant, "10.1.2.3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Matched {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
found := false
|
||||
for _, hit := range res.Matches {
|
||||
if hit.Layer == LayerEntry {
|
||||
found = true
|
||||
if hit.CommunityID == nil || *hit.CommunityID != cid {
|
||||
t.Fatalf("expected default community, got %+v", hit)
|
||||
}
|
||||
if hit.Community != "demo-comm" {
|
||||
t.Fatalf("community value: %+v", hit)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no entry match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupDomainEntryAndSnapshot(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, _, _, _ := m.DemoIDs()
|
||||
|
||||
mod, err := m.CreateModule(tenant, &store.Module{
|
||||
Type: "DOMAINS",
|
||||
Name: "demo-domains",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
comms, _ := m.ListCommunities(tenant)
|
||||
cid := comms[0].ID
|
||||
|
||||
e, err := m.CreateDomainEntry(tenant, mod.ID, &store.DomainEntry{
|
||||
FQDN: "Example.COM.",
|
||||
CommunityID: &cid,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.SetModulePrefixSnapshot(tenant, mod.ID, "hash-d", []store.PrefixRow{
|
||||
{Prefix: "198.51.100.1/32", CommunityID: &cid, Source: "domain"},
|
||||
{Prefix: "203.0.113.9/32", CommunityID: &cid, Source: "other"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
noDNS := func(context.Context, string) ([]netip.Addr, error) { return nil, nil }
|
||||
res, err := LookupWithResolver(context.Background(), m, tenant, "example.com", noDNS)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.QueryKind != KindDomain || res.Normalized != "example.com" {
|
||||
t.Fatalf("kind/normalized: %+v", res)
|
||||
}
|
||||
if !res.Matched {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
|
||||
var entryHit, snapHit, otherSnap bool
|
||||
for _, hit := range res.Matches {
|
||||
if hit.Layer == LayerEntry && hit.EntryID == e.ID {
|
||||
entryHit = true
|
||||
}
|
||||
if hit.Layer == LayerSnapshot && hit.MatchedValue == "198.51.100.1/32" {
|
||||
snapHit = true
|
||||
}
|
||||
if hit.MatchedValue == "203.0.113.9/32" {
|
||||
otherSnap = true
|
||||
}
|
||||
}
|
||||
if !entryHit || !snapHit {
|
||||
t.Fatalf("entry=%v snap=%v matches=%+v", entryHit, snapHit, res.Matches)
|
||||
}
|
||||
if otherSnap {
|
||||
t.Fatal("non-domain snapshot source should be excluded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupDomainResolvedIPAgainstRanges(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, modIP, _, _ := m.DemoIDs()
|
||||
comms, _ := m.ListCommunities(tenant)
|
||||
cid := comms[0].ID
|
||||
if _, err := m.UpdateModule(tenant, modIP, &store.ModulePatch{DefaultCommunityID: &cid}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{
|
||||
Prefix: "203.0.113.0/24",
|
||||
CommunityID: &cid,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.SetModulePrefixSnapshot(tenant, modIP, "hash-r", []store.PrefixRow{
|
||||
{Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "ip_range"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fake := func(_ context.Context, host string) ([]netip.Addr, error) {
|
||||
if host != "google.com" {
|
||||
t.Fatalf("unexpected host %q", host)
|
||||
}
|
||||
return []netip.Addr{netip.MustParseAddr("203.0.113.50")}, nil
|
||||
}
|
||||
|
||||
res, err := LookupWithResolver(context.Background(), m, tenant, "google.com", fake)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.QueryKind != KindDomain {
|
||||
t.Fatalf("kind: %+v", res)
|
||||
}
|
||||
if len(res.ResolvedIPs) != 1 || res.ResolvedIPs[0] != "203.0.113.50" {
|
||||
t.Fatalf("resolved_ips: %+v", res.ResolvedIPs)
|
||||
}
|
||||
if !res.Matched {
|
||||
t.Fatalf("expected IP membership via resolve, got %+v", res)
|
||||
}
|
||||
var viaResolve bool
|
||||
for _, hit := range res.Matches {
|
||||
if hit.ResolvedIP == "203.0.113.50" && hit.MatchedValue == "203.0.113.0/24" {
|
||||
viaResolve = true
|
||||
}
|
||||
}
|
||||
if !viaResolve {
|
||||
t.Fatalf("missing resolved-ip match: %+v", res.Matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupNoMatch(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, _, _, _ := m.DemoIDs()
|
||||
|
||||
res, err := Lookup(context.Background(), m, tenant, "192.0.2.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Matched || res.MatchCount != 0 || len(res.Matches) != 0 {
|
||||
t.Fatalf("expected empty: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupInvalid(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, _, _, _ := m.DemoIDs()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := Lookup(ctx, m, tenant, "")
|
||||
if !errors.Is(err, store.ErrInvalidInput) {
|
||||
t.Fatalf("empty: %v", err)
|
||||
}
|
||||
_, err = Lookup(ctx, m, tenant, "not a host")
|
||||
if !errors.Is(err, store.ErrInvalidInput) {
|
||||
t.Fatalf("spaces: %v", err)
|
||||
}
|
||||
_, err = Lookup(ctx, m, tenant, "localhost")
|
||||
if !errors.Is(err, store.ErrInvalidInput) {
|
||||
t.Fatalf("single label: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFQDN(t *testing.T) {
|
||||
got, ok := normalizeFQDN(" Example.COM. ")
|
||||
if !ok || got != "example.com" {
|
||||
t.Fatalf("got %q ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
|
||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
|
||||
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -133,7 +133,8 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
||||
var doh, dc, cron *string
|
||||
var refresh *int32
|
||||
var last *time.Time
|
||||
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
|
||||
var createdBy *string
|
||||
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy); err != nil {
|
||||
continue
|
||||
}
|
||||
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||
@@ -153,6 +154,9 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
||||
t := last.UTC()
|
||||
m.LastRefreshedAt = &t
|
||||
}
|
||||
if createdBy != nil {
|
||||
m.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
out = append(out, &m)
|
||||
moduleByID[m.ID] = &m
|
||||
}
|
||||
@@ -175,7 +179,7 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
|
||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
|
||||
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY priority, name
|
||||
LIMIT $2 OFFSET $3`, tenantID, limit+1, off)
|
||||
@@ -191,7 +195,8 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
|
||||
var doh, dc, cron *string
|
||||
var refresh *int32
|
||||
var last *time.Time
|
||||
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
|
||||
var createdBy *string
|
||||
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy); err != nil {
|
||||
continue
|
||||
}
|
||||
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||
@@ -211,6 +216,9 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
|
||||
t := last.UTC()
|
||||
m.LastRefreshedAt = &t
|
||||
}
|
||||
if createdBy != nil {
|
||||
m.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
out = append(out, &m)
|
||||
moduleByID[m.ID] = &m
|
||||
}
|
||||
@@ -238,11 +246,12 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
|
||||
var doh, dc, cron *string
|
||||
var refresh *int32
|
||||
var last *time.Time
|
||||
var createdBy *string
|
||||
err := p.pool.QueryRow(ctx, `
|
||||
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
|
||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
|
||||
FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan(
|
||||
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last)
|
||||
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, store.ErrNotFound
|
||||
@@ -265,6 +274,9 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
|
||||
t := last.UTC()
|
||||
m.LastRefreshedAt = &t
|
||||
}
|
||||
if createdBy != nil {
|
||||
m.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||
if err := p.fillModuleDohFields(ctx, &m); err != nil {
|
||||
return nil, err
|
||||
@@ -299,10 +311,14 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
|
||||
lastArg = in.LastRefreshedAt.UTC()
|
||||
}
|
||||
policy := store.NormalizeDohResolverPolicy(in.DohResolverPolicy)
|
||||
var createdBy any
|
||||
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
|
||||
createdBy = v
|
||||
}
|
||||
_, err := p.pool.Exec(ctx, `
|
||||
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, doh_resolver_policy, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
|
||||
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, policy, ri, cronArg, dc, lastArg)
|
||||
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, doh_resolver_policy, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at, created_by_user_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
|
||||
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, policy, ri, cronArg, dc, lastArg, createdBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -402,7 +418,8 @@ func (p *Postgres) ListPeers(tenantID string) []*store.BGPPeer {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled,
|
||||
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}')
|
||||
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}'),
|
||||
created_by_user_id
|
||||
FROM bgp_peer WHERE tenant_id=$1 ORDER BY neighbor`, tenantID)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -412,10 +429,14 @@ func (p *Postgres) ListPeers(tenantID string) []*store.BGPPeer {
|
||||
for rows.Next() {
|
||||
var peer store.BGPPeer
|
||||
var sp *string
|
||||
if err := rows.Scan(&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON); err != nil {
|
||||
var createdBy *string
|
||||
if err := rows.Scan(&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON, &createdBy); err != nil {
|
||||
continue
|
||||
}
|
||||
peer.SpeakerID = sp
|
||||
if createdBy != nil {
|
||||
peer.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
out = append(out, &peer)
|
||||
}
|
||||
return out
|
||||
@@ -425,11 +446,13 @@ func (p *Postgres) GetPeer(tenantID, id string) (*store.BGPPeer, error) {
|
||||
ctx := context.Background()
|
||||
var peer store.BGPPeer
|
||||
var sp *string
|
||||
var createdBy *string
|
||||
err := p.pool.QueryRow(ctx, `
|
||||
SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled,
|
||||
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}')
|
||||
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}'),
|
||||
created_by_user_id
|
||||
FROM bgp_peer WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan(
|
||||
&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON)
|
||||
&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON, &createdBy)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, store.ErrNotFound
|
||||
@@ -437,6 +460,9 @@ func (p *Postgres) GetPeer(tenantID, id string) (*store.BGPPeer, error) {
|
||||
return nil, err
|
||||
}
|
||||
peer.SpeakerID = sp
|
||||
if createdBy != nil {
|
||||
peer.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
return &peer, nil
|
||||
}
|
||||
|
||||
@@ -461,10 +487,14 @@ func (p *Postgres) CreatePeer(tenantID string, in *store.BGPPeer) (*store.BGPPee
|
||||
sp = strings.TrimSpace(*in.SpeakerID)
|
||||
}
|
||||
enabled := store.EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState)
|
||||
var createdBy any
|
||||
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
|
||||
createdBy = v
|
||||
}
|
||||
_, err := p.pool.Exec(ctx, `
|
||||
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json)
|
||||
VALUES ($1,$2,$3,$4::inet, $5, $6, $7::jsonb, $8::jsonb)`,
|
||||
id, tenantID, sp, neighbor, in.RemoteASN, enabled, pol, string(mb))
|
||||
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json, created_by_user_id)
|
||||
VALUES ($1,$2,$3,$4::inet, $5, $6, $7::jsonb, $8::jsonb, $9)`,
|
||||
id, tenantID, sp, neighbor, in.RemoteASN, enabled, pol, string(mb), createdBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const firewallClientSelectCols = `
|
||||
COALESCE(last_apply_prefix_count, 0), COALESCE(last_apply_ip_count, 0),
|
||||
COALESCE(last_apply_packets_dropped, 0), COALESCE(last_apply_packets_accepted, 0),
|
||||
COALESCE(last_apply_source, ''),
|
||||
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at`
|
||||
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at, created_by_user_id`
|
||||
|
||||
func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) {
|
||||
ctx := context.Background()
|
||||
@@ -63,11 +63,15 @@ func (p *Postgres) CreateFirewallClient(tenantID string, in *store.FirewallClien
|
||||
}
|
||||
id := uuid.NewString()
|
||||
ctx := context.Background()
|
||||
var createdBy any
|
||||
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
|
||||
createdBy = v
|
||||
}
|
||||
_, err := p.pool.Exec(ctx, `
|
||||
INSERT INTO firewall_client (id, tenant_id, name, hostname, token_prefix, token_hash, client_version)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
INSERT INTO firewall_client (id, tenant_id, name, hostname, token_prefix, token_hash, client_version, created_by_user_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
||||
id, tenantID, strings.TrimSpace(in.Name), strings.TrimSpace(in.Hostname),
|
||||
in.TokenPrefix, in.TokenHash, strings.TrimSpace(in.ClientVersion))
|
||||
in.TokenPrefix, in.TokenHash, strings.TrimSpace(in.ClientVersion), createdBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -232,11 +236,11 @@ func (p *Postgres) ListFirewallRules(tenantID string, clientID *string) ([]*stor
|
||||
var err error
|
||||
if clientID == nil {
|
||||
rows, err = p.pool.Query(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
|
||||
FROM firewall_rule WHERE tenant_id=$1 AND client_id IS NULL ORDER BY priority`, tenantID)
|
||||
} else {
|
||||
rows, err = p.pool.Query(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
|
||||
FROM firewall_rule WHERE tenant_id=$1 AND client_id=$2 ORDER BY priority`, tenantID, *clientID)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -249,7 +253,7 @@ func (p *Postgres) ListFirewallRules(tenantID string, clientID *string) ([]*stor
|
||||
func (p *Postgres) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*store.FirewallRule, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
|
||||
FROM firewall_rule
|
||||
WHERE tenant_id=$1 AND (client_id IS NULL OR client_id=$2)
|
||||
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, priority`, tenantID, clientID)
|
||||
@@ -263,7 +267,7 @@ func (p *Postgres) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*
|
||||
func (p *Postgres) ListAllFirewallRulesForReplication(tenantID string) ([]*store.FirewallRule, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
|
||||
FROM firewall_rule WHERE tenant_id=$1
|
||||
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, client_id, priority`, tenantID)
|
||||
if err != nil {
|
||||
@@ -294,10 +298,14 @@ func (p *Postgres) CreateFirewallRule(tenantID string, clientID *string, in *sto
|
||||
}
|
||||
id := uuid.NewString()
|
||||
ctx := context.Background()
|
||||
var createdBy any
|
||||
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
|
||||
createdBy = v
|
||||
}
|
||||
_, err := p.pool.Exec(ctx, `
|
||||
INSERT INTO firewall_rule (id, tenant_id, client_id, priority, action, community_id, comment)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
id, tenantID, clientID, priority, strings.ToLower(strings.TrimSpace(in.Action)), in.CommunityID, strings.TrimSpace(in.Comment))
|
||||
INSERT INTO firewall_rule (id, tenant_id, client_id, priority, action, community_id, comment, created_by_user_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
||||
id, tenantID, clientID, priority, strings.ToLower(strings.TrimSpace(in.Action)), in.CommunityID, strings.TrimSpace(in.Comment), createdBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -307,7 +315,7 @@ func (p *Postgres) CreateFirewallRule(tenantID string, clientID *string, in *sto
|
||||
func (p *Postgres) GetFirewallRule(tenantID, id string) (*store.FirewallRule, error) {
|
||||
ctx := context.Background()
|
||||
row := p.pool.QueryRow(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
|
||||
FROM firewall_rule WHERE id=$1 AND tenant_id=$2`, id, tenantID)
|
||||
r, err := scanFirewallRuleRow(row.Scan, tenantID)
|
||||
if err != nil {
|
||||
@@ -423,19 +431,22 @@ func scanFirewallRules(rows pgx.Rows, tenantID string) ([]*store.FirewallRule, e
|
||||
func scanFirewallRuleRow(scan scanFn, tenantID string) (*store.FirewallRule, error) {
|
||||
var r store.FirewallRule
|
||||
r.TenantID = tenantID
|
||||
var clientID, communityID *string
|
||||
if err := scan(&r.ID, &clientID, &r.Priority, &r.Action, &communityID, &r.Comment, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
var clientID, communityID, createdBy *string
|
||||
if err := scan(&r.ID, &clientID, &r.Priority, &r.Action, &communityID, &r.Comment, &r.CreatedAt, &r.UpdatedAt, &createdBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.ClientID = clientID
|
||||
r.CommunityID = communityID
|
||||
if createdBy != nil {
|
||||
r.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient, error) {
|
||||
var c store.FirewallClient
|
||||
c.TenantID = tenantID
|
||||
var approvedBy *string
|
||||
var approvedBy, createdBy *string
|
||||
var lastSeen, lastApply, approved, revoked *time.Time
|
||||
var prefixCount, ipCount *int
|
||||
var packetsDropped, packetsAccepted *int64
|
||||
@@ -444,16 +455,16 @@ func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient,
|
||||
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
|
||||
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
|
||||
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
|
||||
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
|
||||
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked, &createdBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
|
||||
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, createdBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
|
||||
}
|
||||
|
||||
func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
|
||||
var c store.FirewallClient
|
||||
var approvedBy *string
|
||||
var approvedBy, createdBy *string
|
||||
var lastSeen, lastApply, approved, revoked *time.Time
|
||||
var prefixCount, ipCount *int
|
||||
var packetsDropped, packetsAccepted *int64
|
||||
@@ -462,14 +473,14 @@ func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
|
||||
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
|
||||
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
|
||||
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
|
||||
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
|
||||
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked, &createdBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
|
||||
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, createdBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
|
||||
}
|
||||
|
||||
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
|
||||
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy, createdBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
|
||||
c.LastSeenAt = lastSeen
|
||||
c.LastApplyAt = lastApply
|
||||
c.ApprovedAt = approved
|
||||
@@ -477,6 +488,9 @@ func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, appr
|
||||
if approvedBy != nil {
|
||||
c.ApprovedByAPIKeyID = *approvedBy
|
||||
}
|
||||
if createdBy != nil {
|
||||
c.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
if prefixCount != nil {
|
||||
c.LastApplyPrefixCount = *prefixCount
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ type Backend interface {
|
||||
ListAllFirewallRulesForClient(tenantID, clientID string) ([]*FirewallRule, error)
|
||||
ListAllFirewallRulesForReplication(tenantID string) ([]*FirewallRule, error)
|
||||
CreateFirewallRule(tenantID string, clientID *string, in *FirewallRuleCreate) (*FirewallRule, error)
|
||||
GetFirewallRule(tenantID, ruleID string) (*FirewallRule, error)
|
||||
UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error)
|
||||
DeleteFirewallRule(tenantID, ruleID string) error
|
||||
ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error
|
||||
|
||||
@@ -29,15 +29,17 @@ type FirewallClient struct {
|
||||
ApprovedAt *time.Time `json:"approved_at,omitempty"`
|
||||
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
CreatedByUserID string `json:"created_by_user_id,omitempty"`
|
||||
}
|
||||
|
||||
// FirewallClientCreate is input for enroll (token hash supplied by caller).
|
||||
type FirewallClientCreate struct {
|
||||
Name string
|
||||
Hostname string
|
||||
TokenPrefix string
|
||||
TokenHash []byte
|
||||
ClientVersion string
|
||||
Name string
|
||||
Hostname string
|
||||
TokenPrefix string
|
||||
TokenHash []byte
|
||||
ClientVersion string
|
||||
CreatedByUserID string
|
||||
}
|
||||
|
||||
// FirewallClientPatch is a partial update for operator edits.
|
||||
@@ -62,23 +64,25 @@ type FirewallClientReplicationRow struct {
|
||||
|
||||
// FirewallRule is one block/accept policy rule.
|
||||
type FirewallRule struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
ClientID *string `json:"client_id,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
Action string `json:"action"`
|
||||
CommunityID *string `json:"community_id,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
ClientID *string `json:"client_id,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
Action string `json:"action"`
|
||||
CommunityID *string `json:"community_id,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedByUserID string `json:"created_by_user_id,omitempty"`
|
||||
}
|
||||
|
||||
// FirewallRuleCreate is input for creating a rule.
|
||||
type FirewallRuleCreate struct {
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Action string `json:"action"`
|
||||
CommunityID *string `json:"community_id,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Action string `json:"action"`
|
||||
CommunityID *string `json:"community_id,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
CreatedByUserID string `json:"-"`
|
||||
}
|
||||
|
||||
// FirewallRulePatch is a partial rule update.
|
||||
|
||||
@@ -95,6 +95,7 @@ type Module struct {
|
||||
DohResolverPolicy string
|
||||
LastRefreshedAt *time.Time
|
||||
DeletedAt *time.Time
|
||||
CreatedByUserID string // portal JWT sub; empty = system / API key
|
||||
}
|
||||
|
||||
type Revision struct {
|
||||
@@ -112,15 +113,16 @@ type Revision struct {
|
||||
|
||||
// BGPPeer maps to bgp_peer (+ display fields in meta).
|
||||
type BGPPeer struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
SpeakerID *string `json:"bgp_speaker_id"`
|
||||
Name string `json:"name"`
|
||||
Neighbor string `json:"neighbor"`
|
||||
RemoteASN int64 `json:"remote_asn"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SessionState string `json:"session_state"`
|
||||
PoliciesJSON string `json:"policies_json"`
|
||||
ID string `json:"id,omitempty"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
SpeakerID *string `json:"bgp_speaker_id"`
|
||||
Name string `json:"name"`
|
||||
Neighbor string `json:"neighbor"`
|
||||
RemoteASN int64 `json:"remote_asn"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SessionState string `json:"session_state"`
|
||||
PoliciesJSON string `json:"policies_json"`
|
||||
CreatedByUserID string `json:"created_by_user_id,omitempty"`
|
||||
}
|
||||
|
||||
type Speaker struct {
|
||||
|
||||
@@ -32,6 +32,7 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
|
||||
DohProfileIDs: append([]string(nil), in.DohProfileIDs...),
|
||||
DohResolverPolicy: in.DohResolverPolicy,
|
||||
LastRefreshedAt: in.LastRefreshedAt,
|
||||
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
|
||||
}
|
||||
NormalizeModuleDoh(mod)
|
||||
m.modules[id] = mod
|
||||
@@ -680,6 +681,7 @@ func (m *Memory) CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error) {
|
||||
Neighbor: neighbor, RemoteASN: in.RemoteASN,
|
||||
Enabled: EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState),
|
||||
SessionState: in.SessionState, PoliciesJSON: in.PoliciesJSON,
|
||||
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
|
||||
}
|
||||
m.peers[id] = p
|
||||
return p, nil
|
||||
|
||||
@@ -49,14 +49,15 @@ func (m *Memory) CreateFirewallClient(tenantID string, in *FirewallClientCreate)
|
||||
id := uuid.NewString()
|
||||
rec := &firewallClientRec{
|
||||
FirewallClient: FirewallClient{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Hostname: strings.TrimSpace(in.Hostname),
|
||||
TokenPrefix: in.TokenPrefix,
|
||||
Status: "pending",
|
||||
ClientVersion: strings.TrimSpace(in.ClientVersion),
|
||||
CreatedAt: now,
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Hostname: strings.TrimSpace(in.Hostname),
|
||||
TokenPrefix: in.TokenPrefix,
|
||||
Status: "pending",
|
||||
ClientVersion: strings.TrimSpace(in.ClientVersion),
|
||||
CreatedAt: now,
|
||||
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
|
||||
},
|
||||
TokenHash: append([]byte(nil), in.TokenHash...),
|
||||
}
|
||||
@@ -311,20 +312,31 @@ func (m *Memory) CreateFirewallRule(tenantID string, clientID *string, in *Firew
|
||||
now := time.Now().UTC()
|
||||
id := uuid.NewString()
|
||||
rule := &FirewallRule{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
ClientID: clientID,
|
||||
Priority: priority,
|
||||
Action: strings.ToLower(strings.TrimSpace(in.Action)),
|
||||
CommunityID: in.CommunityID,
|
||||
Comment: strings.TrimSpace(in.Comment),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
ClientID: clientID,
|
||||
Priority: priority,
|
||||
Action: strings.ToLower(strings.TrimSpace(in.Action)),
|
||||
CommunityID: in.CommunityID,
|
||||
Comment: strings.TrimSpace(in.Comment),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
|
||||
}
|
||||
m.firewallRules[id] = rule
|
||||
return firewallRuleCopy(rule), nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetFirewallRule(tenantID, ruleID string) (*FirewallRule, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
rule, ok := m.firewallRules[ruleID]
|
||||
if !ok || rule.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return firewallRuleCopy(rule), nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error) {
|
||||
if patch == nil {
|
||||
return nil, ErrInvalidInput
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
// Ownership helpers for portal JWT resource scoping.
|
||||
|
||||
// SeesAllOwned is true for API keys and portal admins (no per-user filter).
|
||||
func SeesAllOwned(kind string, isAdmin bool) bool {
|
||||
if kind != "jwt" {
|
||||
return true
|
||||
}
|
||||
return isAdmin
|
||||
}
|
||||
|
||||
// CanAccessOwned reports whether the actor may see/edit a resource with createdBy.
|
||||
// Empty createdBy (legacy/API-key-created) is visible only when SeesAllOwned.
|
||||
func CanAccessOwned(kind string, isAdmin bool, userID, createdBy string) bool {
|
||||
if SeesAllOwned(kind, isAdmin) {
|
||||
return true
|
||||
}
|
||||
if createdBy == "" {
|
||||
return false
|
||||
}
|
||||
return createdBy == userID
|
||||
}
|
||||
|
||||
// FilterOwnedStrings keeps items whose owner matches the actor.
|
||||
func FilterOwned[T any](items []T, owner func(T) string, kind string, isAdmin bool, userID string) []T {
|
||||
if SeesAllOwned(kind, isAdmin) {
|
||||
return items
|
||||
}
|
||||
out := make([]T, 0, len(items))
|
||||
for _, it := range items {
|
||||
if CanAccessOwned(kind, isAdmin, userID, owner(it)) {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSeesAllOwned(t *testing.T) {
|
||||
if !SeesAllOwned("apikey", false) {
|
||||
t.Fatal("api keys must see all rows")
|
||||
}
|
||||
if !SeesAllOwned("jwt", true) {
|
||||
t.Fatal("admin jwt must see all rows")
|
||||
}
|
||||
if SeesAllOwned("jwt", false) {
|
||||
t.Fatal("non-admin jwt must not see all rows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanAccessOwned(t *testing.T) {
|
||||
if !CanAccessOwned("apikey", false, "", "someone") {
|
||||
t.Fatal("api key must access any owner")
|
||||
}
|
||||
if !CanAccessOwned("jwt", true, "admin", "user-1") {
|
||||
t.Fatal("admin jwt must access any owner")
|
||||
}
|
||||
if !CanAccessOwned("jwt", false, "user-1", "user-1") {
|
||||
t.Fatal("owner must access their resource")
|
||||
}
|
||||
if CanAccessOwned("jwt", false, "user-1", "user-2") {
|
||||
t.Fatal("non-owner must not access foreign resource")
|
||||
}
|
||||
if CanAccessOwned("jwt", false, "user-1", "") {
|
||||
t.Fatal("non-admin jwt must not see legacy rows without owner")
|
||||
}
|
||||
}
|
||||
|
||||
type ownRow struct {
|
||||
id string
|
||||
owner string
|
||||
}
|
||||
|
||||
func TestFilterOwned(t *testing.T) {
|
||||
rows := []ownRow{
|
||||
{"a", "user-1"},
|
||||
{"b", "user-2"},
|
||||
{"c", ""},
|
||||
}
|
||||
get := func(r ownRow) string { return r.owner }
|
||||
|
||||
got := FilterOwned(rows, get, "apikey", false, "")
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("apikey filter: got=%d want 3", len(got))
|
||||
}
|
||||
|
||||
got = FilterOwned(rows, get, "jwt", true, "any")
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("admin jwt filter: got=%d want 3", len(got))
|
||||
}
|
||||
|
||||
got = FilterOwned(rows, get, "jwt", false, "user-1")
|
||||
if len(got) != 1 || got[0].id != "a" {
|
||||
t.Fatalf("user-1 filter: got=%+v want [a]", got)
|
||||
}
|
||||
|
||||
got = FilterOwned(rows, get, "jwt", false, "user-3")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("unknown user filter: got=%+v want []", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
DROP INDEX IF EXISTS idx_firewall_rule_created_by;
|
||||
DROP INDEX IF EXISTS idx_firewall_client_created_by;
|
||||
DROP INDEX IF EXISTS idx_bgp_peer_created_by;
|
||||
DROP INDEX IF EXISTS idx_module_created_by;
|
||||
|
||||
ALTER TABLE firewall_rule DROP COLUMN IF EXISTS created_by_user_id;
|
||||
ALTER TABLE firewall_client DROP COLUMN IF EXISTS created_by_user_id;
|
||||
ALTER TABLE bgp_peer DROP COLUMN IF EXISTS created_by_user_id;
|
||||
ALTER TABLE module DROP COLUMN IF EXISTS created_by_user_id;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Ownership for portal JWT users (modules, peers, firewall).
|
||||
ALTER TABLE module ADD COLUMN IF NOT EXISTS created_by_user_id TEXT;
|
||||
ALTER TABLE bgp_peer ADD COLUMN IF NOT EXISTS created_by_user_id TEXT;
|
||||
ALTER TABLE firewall_client ADD COLUMN IF NOT EXISTS created_by_user_id TEXT;
|
||||
ALTER TABLE firewall_rule ADD COLUMN IF NOT EXISTS created_by_user_id TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_module_created_by ON module (tenant_id, created_by_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bgp_peer_created_by ON bgp_peer (tenant_id, created_by_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_firewall_client_created_by ON firewall_client (tenant_id, created_by_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_firewall_rule_created_by ON firewall_rule (tenant_id, created_by_user_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_firewall_rule_created_by;
|
||||
DROP INDEX IF EXISTS idx_firewall_client_created_by;
|
||||
DROP INDEX IF EXISTS idx_bgp_peer_created_by;
|
||||
DROP INDEX IF EXISTS idx_module_created_by;
|
||||
|
||||
-- SQLite: recreate tables without column is heavy; leave columns (no-op down for v1).
|
||||
-- Down migration intentionally empty for SQLite ALTER DROP COLUMN compatibility.
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Ownership for portal JWT users (modules, peers, firewall).
|
||||
ALTER TABLE module ADD COLUMN created_by_user_id TEXT;
|
||||
ALTER TABLE bgp_peer ADD COLUMN created_by_user_id TEXT;
|
||||
ALTER TABLE firewall_client ADD COLUMN created_by_user_id TEXT;
|
||||
ALTER TABLE firewall_rule ADD COLUMN created_by_user_id TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_module_created_by ON module (tenant_id, created_by_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bgp_peer_created_by ON bgp_peer (tenant_id, created_by_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_firewall_client_created_by ON firewall_client (tenant_id, created_by_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_firewall_rule_created_by ON firewall_rule (tenant_id, created_by_user_id);
|
||||
@@ -25,7 +25,7 @@ import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH = "240px"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
Reference in New Issue
Block a user