Compare commits

..
4 Commits
Author SHA1 Message Date
Denozordec 5d1102b497 feat(firewall): refactor FirewallPage to use new grid components and improve loading states
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 43s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m13s
Updated the FirewallPage component to replace the existing table implementations with FirewallClientsGrid and FirewallRulesGrid for better performance and user experience. Integrated QueryState for handling loading and error states, enhancing the UI responsiveness. Removed deprecated ClientsTable and RulesTable components to streamline the codebase.
2026-07-09 01:41:09 +07:00
Denozordec 5edbd656ba feat(firewall): add packet statistics tracking for firewall clients
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 29s
CI / web (push) Successful in 1m6s
CI / go (push) Successful in 1m23s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m42s
Enhanced the firewall client functionality by introducing packet statistics tracking, including the cumulative count of packets dropped and accepted. Updated the API to support these new fields and modified the database schema accordingly. Improved the firewall scripts to collect and report packet statistics, ensuring better visibility into client performance. Adjusted the UI components to display packet counts in the clients table, enhancing user experience and monitoring capabilities.
2026-07-09 00:50:38 +07:00
Denozordec 4a4c11c6bf feat(firewall): rename revoke function to delete and update related UI components
Refactored the revoke functionality for firewall clients to be more accurately represented as a delete operation. Updated the corresponding API call to use the DELETE method and modified the UI components to reflect this change, including confirmation dialogs and success messages. Adjusted tests to ensure the new delete functionality works as intended.
2026-07-09 00:27:50 +07:00
Denozordec e51999c908 feat(firewall): add revoke functionality for firewall clients and enhance status badge
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 30s
CI / web (push) Successful in 56s
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 27s
CI / release (push) Successful in 4m19s
Implemented the ability to revoke approved firewall clients and reject pending requests through new API endpoints. Updated the StatusBadge component to include additional status variants for 'approved', 'revoked', 'pending', and 'block'. Enhanced the FirewallPage UI to support client revocation and rejection actions, integrating confirmation dialogs for user interactions. Updated tests to ensure proper functionality of the new revoke feature.
2026-07-08 23:27:29 +07:00
20 changed files with 912 additions and 189 deletions
@@ -0,0 +1,215 @@
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import type { FirewallClient } from '@/types/api'
function formatPacketCount(value?: number | null): string | null {
if (value == null || value <= 0) return null
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
return String(value)
}
export interface FirewallClientsGridProps {
clients: FirewallClient[]
isLoading?: boolean
onApprove: (id: string) => void
onReject: (id: string) => void
approvePending?: boolean
rejectPending?: boolean
emptyTitle?: string
}
export function FirewallClientsGrid({
clients,
isLoading = false,
onApprove,
onReject,
approvePending = false,
rejectPending = false,
emptyTitle = 'Нет клиентов',
}: FirewallClientsGridProps) {
const columns = useMemo<ColumnDef<FirewallClient>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<div>
<div className="font-medium">{row.original.name}</div>
<div className="text-muted-foreground text-xs">
{row.original.hostname || row.original.token_prefix}
</div>
</div>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: 'Статус' },
},
{
id: 'last_seen_at',
accessorFn: (row) => row.last_seen_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Last seen" />,
cell: ({ row }) => (
<span className="text-xs">{row.original.last_seen_at?.slice(0, 19) ?? '—'}</span>
),
sortingFn: (a, b) => {
const av = a.original.last_seen_at ?? ''
const bv = b.original.last_seen_at ?? ''
return av.localeCompare(bv)
},
meta: { headerTitle: 'Last seen' },
},
{
id: 'apply',
enableSorting: false,
header: 'Apply',
cell: ({ row }) => {
const c = row.original
return (
<span className="text-xs">
{c.last_apply_status ?? '—'}
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
</span>
)
},
meta: { headerTitle: 'Apply' },
},
{
id: 'packets',
enableSorting: false,
header: 'Пакеты',
cell: ({ row }) => {
const dropped = formatPacketCount(row.original.last_apply_packets_dropped)
const accepted = formatPacketCount(row.original.last_apply_packets_accepted)
if (!dropped && !accepted) {
return <span className="text-muted-foreground text-xs"></span>
}
return (
<span className="text-muted-foreground text-xs">
{dropped ? <span className="text-destructive">{dropped}</span> : null}
{dropped && accepted ? ' · ' : null}
{accepted ? <span className="text-success">{accepted}</span> : null}
</span>
)
},
meta: { headerTitle: 'Пакеты' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const c = row.original
return (
<div className="flex justify-end gap-2">
{c.status === 'pending' ? (
<>
<Button
size="sm"
variant="outline"
type="button"
disabled={approvePending}
onClick={() => onApprove(c.id)}
>
Одобрить
</Button>
<ConfirmDialog
trigger={
<Button
size="sm"
variant="outline"
type="button"
className="text-destructive"
disabled={rejectPending}
>
Отклонить
</Button>
}
title="Отклонить запрос?"
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
confirmLabel="Отклонить"
destructive
onConfirm={() => onReject(c.id)}
/>
</>
) : null}
{c.status === 'approved' ? (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
type="button"
className="text-destructive"
disabled={rejectPending}
>
Удалить
</Button>
}
title="Удалить клиент?"
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
confirmLabel="Удалить"
destructive
onConfirm={() => onReject(c.id)}
/>
) : null}
</div>
)
},
},
],
[approvePending, onApprove, onReject, rejectPending],
)
const table = useReactTable({
data: clients,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
initialState: { pagination: { pageSize: 10 } },
})
return (
<DataGrid
table={table}
recordCount={clients.length}
isLoading={isLoading}
emptyMessage={emptyTitle}
tableLayout={{ headerSticky: true, dense: true }}
>
<DataGridContainer>
<DataGridTable />
</DataGridContainer>
<DataGridPagination
sizes={[10, 25, 50]}
sizesLabel="Показать"
sizesDescription="на странице"
info="{from}{to} из {count}"
rowsPerPageLabel="Строк на странице"
previousPageLabel="Предыдущая страница"
nextPageLabel="Следующая страница"
/>
</DataGrid>
)
}
@@ -0,0 +1,142 @@
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { communityLabel } from '@/lib/modules/helpers'
import type { BgpCommunity, FirewallRule } from '@/types/api'
export interface FirewallRulesGridProps {
rules: FirewallRule[]
communities: BgpCommunity[]
isLoading?: boolean
onDelete: (id: string) => void
deletePending?: boolean
emptyTitle?: string
}
export function FirewallRulesGrid({
rules,
communities,
isLoading = false,
onDelete,
deletePending = false,
emptyTitle = 'Нет правил — blocklist пуст (default accept).',
}: FirewallRulesGridProps) {
const columns = useMemo<ColumnDef<FirewallRule>[]>(
() => [
{
accessorKey: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="#" />,
cell: ({ row }) => row.original.priority,
meta: { headerTitle: '#' },
},
{
accessorKey: 'action',
header: ({ column }) => <DataGridColumnHeader column={column} title="Действие" />,
cell: ({ row }) => (
<StatusBadge status={row.original.action} label={row.original.action} />
),
meta: { headerTitle: 'Действие' },
},
{
id: 'community',
enableSorting: false,
header: 'Community',
cell: ({ row }) => (
<span className="text-sm">
{row.original.community_id
? communityLabel(row.original.community_id, communities)
: 'Все'}
</span>
),
meta: { headerTitle: 'Community' },
},
{
accessorKey: 'comment',
enableSorting: false,
header: 'Комментарий',
cell: ({ row }) => row.original.comment || '—',
meta: { headerTitle: 'Комментарий' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const r = row.original
return (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
type="button"
className="text-destructive"
disabled={deletePending}
>
Удалить
</Button>
}
title="Удалить правило?"
description={
r.comment
? `Правило #${r.priority} (${r.action}): ${r.comment}`
: `Правило #${r.priority} (${r.action}) будет удалено.`
}
confirmLabel="Удалить"
destructive
onConfirm={() => onDelete(r.id)}
/>
)
},
},
],
[communities, deletePending, onDelete],
)
const table = useReactTable({
data: rules,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
initialState: { pagination: { pageSize: 10 } },
})
return (
<DataGrid
table={table}
recordCount={rules.length}
isLoading={isLoading}
emptyMessage={emptyTitle}
tableLayout={{ headerSticky: true, dense: true }}
>
<DataGridContainer>
<DataGridTable />
</DataGridContainer>
<DataGridPagination
sizes={[10, 25, 50]}
sizesLabel="Показать"
sizesDescription="на странице"
info="{from}{to} из {count}"
rowsPerPageLabel="Строк на странице"
previousPageLabel="Предыдущая страница"
nextPageLabel="Следующая страница"
/>
</DataGrid>
)
}
+5
View File
@@ -22,6 +22,11 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
stale: 'warning', stale: 'warning',
warning: 'warning', warning: 'warning',
mismatch: 'warning', mismatch: 'warning',
pending: 'warning',
approved: 'success',
revoked: 'destructive',
block: 'destructive',
accept: 'success',
} }
export function StatusBadge({ status, label }: { status: string; label?: string }) { export function StatusBadge({ status, label }: { status: string; label?: string }) {
+17
View File
@@ -1,4 +1,6 @@
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query' import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON } from '@/lib/api-client' import { apiJSON } from '@/lib/api-client'
import type { import type {
FirewallClient, FirewallClient,
@@ -51,8 +53,23 @@ export function useApproveFirewallClient() {
mutationFn: (id: string) => mutationFn: (id: string) =>
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }), apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
onSuccess: () => { onSuccess: () => {
toast.success('Клиент одобрен')
void qc.invalidateQueries({ queryKey: firewallKeys.clients() }) void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
}, },
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось одобрить'),
})
}
export function useDeleteFirewallClient() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<void>(`/v1/firewall/clients/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Клиент удалён')
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить'),
}) })
} }
+71 -123
View File
@@ -10,19 +10,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evob
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid'
import { FirewallRulesGrid } from '@/components/firewall/firewall-rules-grid'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { CommunitySelect } from '@/components/modules/community-select' import { CommunitySelect } from '@/components/modules/community-select'
import { StatusBadge } from '@/components/status-badge' import { QueryState } from '@/components/query-state'
import { communityLabel } from '@/lib/modules/helpers' import { TableSkeleton } from '@/components/skeletons'
import { directoriesCommunitiesQueryOptions } from '@/queries/directories' import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
import { import {
firewallClientsQueryOptions, firewallClientsQueryOptions,
@@ -30,9 +24,9 @@ import {
firewallRulesQueryOptions, firewallRulesQueryOptions,
useApproveFirewallClient, useApproveFirewallClient,
useCreateFirewallRule, useCreateFirewallRule,
useDeleteFirewallClient,
useDeleteFirewallRule, useDeleteFirewallRule,
} from '@/queries/firewall' } from '@/queries/firewall'
import type { BgpCommunity, FirewallClient } from '@/types/api'
function httpsOrigin(origin: string): string { function httpsOrigin(origin: string): string {
try { try {
@@ -54,6 +48,7 @@ function FirewallPage() {
const clientsQ = useQuery(firewallClientsQueryOptions()) const clientsQ = useQuery(firewallClientsQueryOptions())
const rulesQ = useQuery(firewallRulesQueryOptions('tenant')) const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
const approve = useApproveFirewallClient() const approve = useApproveFirewallClient()
const deleteClient = useDeleteFirewallClient()
const createRule = useCreateFirewallRule() const createRule = useCreateFirewallRule()
const deleteRule = useDeleteFirewallRule() const deleteRule = useDeleteFirewallRule()
@@ -79,8 +74,13 @@ function FirewallPage() {
const communities = communitiesQ.data?.items ?? [] const communities = communitiesQ.data?.items ?? []
const clients = clientsQ.data?.items ?? [] const { activeClients, pending } = useMemo(() => {
const pending = clients.filter((c) => c.status === 'pending') const all = clientsQ.data?.items ?? []
return {
activeClients: all.filter((c) => c.status !== 'revoked'),
pending: all.filter((c) => c.status === 'pending'),
}
}, [clientsQ.data?.items])
const rules = rulesQ.data?.items ?? [] const rules = rulesQ.data?.items ?? []
const installCmd = useMemo(() => { const installCmd = useMemo(() => {
@@ -187,13 +187,31 @@ function FirewallPage() {
<Tabs defaultValue="clients"> <Tabs defaultValue="clients">
<TabsList> <TabsList>
<TabsTrigger value="clients">Клиенты ({clients.length})</TabsTrigger> <TabsTrigger value="clients">Клиенты ({activeClients.length})</TabsTrigger>
<TabsTrigger value="rules">Правила ({rules.length})</TabsTrigger> <TabsTrigger value="rules">Правила ({rules.length})</TabsTrigger>
<TabsTrigger value="requests">Запросы ({pending.length})</TabsTrigger> <TabsTrigger value="requests">Запросы ({pending.length})</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="clients" className="mt-4"> <TabsContent value="clients" className="mt-4">
<ClientsTable clients={clients} onApprove={(id) => approve.mutate(id)} /> <QueryState
data={clientsQ.data}
isLoading={clientsQ.isLoading}
isError={clientsQ.isError}
error={clientsQ.error}
onRetry={() => void clientsQ.refetch()}
skeleton={<TableSkeleton rows={5} cols={6} />}
>
{() => (
<FirewallClientsGrid
clients={activeClients}
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => deleteClient.mutate(id)}
approvePending={approve.isPending}
rejectPending={deleteClient.isPending}
/>
)}
</QueryState>
</TabsContent> </TabsContent>
<TabsContent value="rules" className="mt-4 space-y-4"> <TabsContent value="rules" className="mt-4 space-y-4">
@@ -243,119 +261,49 @@ function FirewallPage() {
Добавить правило Добавить правило
</Button> </Button>
</div> </div>
<RulesTable <QueryState
rules={rules} data={rulesQ.data}
communities={communities} isLoading={rulesQ.isLoading}
onDelete={(id) => deleteRule.mutate(id)} isError={rulesQ.isError}
/> error={rulesQ.error}
onRetry={() => void rulesQ.refetch()}
skeleton={<TableSkeleton rows={5} cols={5} />}
>
{() => (
<FirewallRulesGrid
rules={rules}
communities={communities}
isLoading={rulesQ.isFetching && !rulesQ.isLoading}
onDelete={(id) => deleteRule.mutate(id)}
deletePending={deleteRule.isPending}
/>
)}
</QueryState>
</TabsContent> </TabsContent>
<TabsContent value="requests" className="mt-4"> <TabsContent value="requests" className="mt-4">
<ClientsTable <QueryState
clients={pending} data={clientsQ.data}
onApprove={(id) => approve.mutate(id)} isLoading={clientsQ.isLoading}
emptyTitle="Нет pending-запросов" isError={clientsQ.isError}
/> error={clientsQ.error}
onRetry={() => void clientsQ.refetch()}
skeleton={<TableSkeleton rows={3} cols={6} />}
>
{() => (
<FirewallClientsGrid
clients={pending}
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => deleteClient.mutate(id)}
approvePending={approve.isPending}
rejectPending={deleteClient.isPending}
emptyTitle="Нет pending-запросов"
/>
)}
</QueryState>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
) )
} }
function ClientsTable({
clients,
onApprove,
emptyTitle = 'Нет клиентов',
}: {
clients: FirewallClient[]
onApprove: (id: string) => void
emptyTitle?: string
}) {
if (clients.length === 0) {
return <p className="text-muted-foreground text-sm">{emptyTitle}</p>
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Last seen</TableHead>
<TableHead>Apply</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{clients.map((c) => (
<TableRow key={c.id}>
<TableCell>
<div className="font-medium">{c.name}</div>
<div className="text-muted-foreground text-xs">{c.hostname || c.token_prefix}</div>
</TableCell>
<TableCell>
<StatusBadge status={c.status} />
</TableCell>
<TableCell className="text-xs">{c.last_seen_at?.slice(0, 19) ?? '—'}</TableCell>
<TableCell className="text-xs">
{c.last_apply_status ?? '—'}
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
</TableCell>
<TableCell>
{c.status === 'pending' ? (
<Button size="sm" variant="outline" onClick={() => onApprove(c.id)}>
Approve
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function RulesTable({
rules,
communities,
onDelete,
}: {
rules: { id: string; priority: number; action: string; community_id?: string | null; comment?: string }[]
communities: BgpCommunity[]
onDelete: (id: string) => void
}) {
if (rules.length === 0) {
return <p className="text-muted-foreground text-sm">Нет правил blocklist пуст (default accept).</p>
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Действие</TableHead>
<TableHead>Community</TableHead>
<TableHead>Комментарий</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>
<StatusBadge status={r.action} label={r.action} />
</TableCell>
<TableCell className="text-sm">
{r.community_id ? communityLabel(r.community_id, communities) : 'Все'}
</TableCell>
<TableCell>{r.comment || '—'}</TableCell>
<TableCell>
<Button size="sm" variant="ghost" onClick={() => onDelete(r.id)}>
Удалить
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+2
View File
@@ -356,6 +356,8 @@ export type FirewallClient = {
last_apply_at?: string | null last_apply_at?: string | null
last_apply_status?: string last_apply_status?: string
last_apply_prefix_count?: number last_apply_prefix_count?: number
last_apply_packets_dropped?: number
last_apply_packets_accepted?: number
last_apply_source?: string last_apply_source?: string
client_version?: string client_version?: string
created_at: string created_at: string
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} {"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
+58
View File
@@ -1605,6 +1605,14 @@ components:
type: string type: string
last_apply_prefix_count: last_apply_prefix_count:
type: integer type: integer
last_apply_packets_dropped:
type: integer
format: int64
description: Cumulative packets dropped by blocklist rule (from client kernel counter).
last_apply_packets_accepted:
type: integer
format: int64
description: Cumulative packets accepted past blocklist chain (nft counter accept rule).
client_version: client_version:
type: string type: string
@@ -4472,6 +4480,31 @@ paths:
default: default:
$ref: "#/components/responses/DefaultProblem" $ref: "#/components/responses/DefaultProblem"
/v1/firewall/clients/{id}/revoke:
post:
tags: [Firewall]
summary: Reject pending or revoke approved client
operationId: revokeFirewallClient
parameters:
- name: id
in: path
required: true
schema:
$ref: "#/components/schemas/ResourceId"
responses:
"200":
description: Revoked
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [revoked]
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/rules: /v1/firewall/rules:
get: get:
tags: [Firewall] tags: [Firewall]
@@ -4522,6 +4555,31 @@ paths:
tags: [Firewall] tags: [Firewall]
summary: Report last apply status summary: Report last apply status
operationId: firewallApplyReport operationId: firewallApplyReport
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
status:
type: string
error:
type: string
prefix_count:
type: integer
ip_count:
type: integer
packets_dropped:
type: integer
format: int64
packets_accepted:
type: integer
format: int64
kernel_method:
type: string
source:
type: string
responses: responses:
"200": "200":
description: OK description: OK
+121 -13
View File
@@ -147,8 +147,119 @@ if [[ -z "${TOTAL// }" ]]; then
TOTAL=${#PREFIXES[@]} TOTAL=${#PREFIXES[@]}
fi fi
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
KERNEL_METHOD=""
APPLIED_V4=0
count_ipv4_prefixes() {
local n=0 p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
n=$((n + 1))
done
APPLIED_V4=$n
}
nft_rule_packets() {
local line=$1
if [[ "$line" =~ counter[[:space:]]+packets[[:space:]]+([0-9]+) ]]; then
echo "${BASH_REMATCH[1]}"
else
echo 0
fi
}
ensure_nft_counters() {
local table=inet name=evobgp_blocklist
nft list chain "$table" "$name" input >/dev/null 2>&1 || return 0
local drop_line
drop_line=$(nft -a list chain "$table" "$name" input 2>/dev/null | grep 'ip saddr @v4' | grep drop | head -1 || true)
if [[ -n "$drop_line" && "$drop_line" != *counter* ]]; then
local handle
handle=$(echo "$drop_line" | sed -n 's/.*# handle \([0-9]\+\).*/\1/p')
if [[ -n "$handle" ]]; then
nft delete rule "$table" "$name" input handle "$handle" 2>>"$LOG_FILE" || true
drop_line=""
fi
fi
if [[ -z "$drop_line" ]]; then
nft add rule "$table" "$name" input ip saddr @v4 counter drop
fi
if ! nft list chain "$table" "$name" input 2>/dev/null | grep -qE '[[:space:]]counter[[:space:]]+accept'; then
nft add rule "$table" "$name" input counter accept
fi
}
collect_nft_packet_stats() {
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
local line pkts
while IFS= read -r line; do
if [[ "$line" == *"ip saddr @v4"* && "$line" == *drop* ]]; then
pkts=$(nft_rule_packets "$line")
[[ -n "$pkts" ]] && PACKETS_DROPPED=$pkts
elif [[ "$line" == *counter* && "$line" == *accept* && "$line" != *@v4* ]]; then
pkts=$(nft_rule_packets "$line")
[[ -n "$pkts" ]] && PACKETS_ACCEPTED=$pkts
fi
done < <(nft list chain inet evobgp_blocklist input 2>/dev/null || true)
}
collect_ipset_packet_stats() {
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
local pkts
pkts=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/match-set evobgp_blocklist_v4/ {print $1; exit}')
[[ "$pkts" =~ ^[0-9]+$ ]] && PACKETS_DROPPED=$pkts
}
collect_packet_stats() {
case "${KERNEL_METHOD:-$BACKEND}" in
nft)
ensure_nft_counters
collect_nft_packet_stats
;;
ipset)
collect_ipset_packet_stats
;;
iptables)
PACKETS_DROPPED=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/DROP/ {s+=$1} END {print s+0}')
PACKETS_ACCEPTED=0
;;
*)
if command -v nft >/dev/null 2>&1 && nft list chain inet evobgp_blocklist input >/dev/null 2>&1; then
KERNEL_METHOD=nft
ensure_nft_counters
collect_nft_packet_stats
elif iptables -L INPUT -v -n -x 2>/dev/null | grep -q 'evobgp_blocklist_v4'; then
KERNEL_METHOD=ipset
collect_ipset_packet_stats
fi
;;
esac
}
send_client_reports() {
collect_packet_stats
local km="${KERNEL_METHOD:-$BACKEND}"
local report
report=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"packets_dropped":%s,"packets_accepted":%s,"source":"cp","kernel_method":"%s"}' \
"${TOTAL:-0}" "${APPLIED_V4:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "$km")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$report" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true
}
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
log "unchanged hash $HASH — skip kernel apply" count_ipv4_prefixes
log "unchanged hash $HASH — skip kernel apply (ipv4=${APPLIED_V4})"
send_client_reports
exit 0 exit 0
fi fi
@@ -184,8 +295,11 @@ apply_nft() {
nft list chain "$table" "$name" input >/dev/null 2>&1 || { nft list chain "$table" "$name" input >/dev/null 2>&1 || {
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }' nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
nft add rule "$table" "$name" input ip saddr @v4 drop nft add rule "$table" "$name" input ip saddr @v4 counter drop
nft add rule "$table" "$name" input counter accept
} }
ensure_nft_counters
KERNEL_METHOD=nft
APPLIED_V4=${#v4[@]} APPLIED_V4=${#v4[@]}
} }
@@ -202,6 +316,7 @@ apply_ipset() {
done done
iptables -C INPUT -m set --match-set "$set" src -j DROP 2>/dev/null || \ iptables -C INPUT -m set --match-set "$set" src -j DROP 2>/dev/null || \
iptables -I INPUT -m set --match-set "$set" src -j DROP iptables -I INPUT -m set --match-set "$set" src -j DROP
KERNEL_METHOD=ipset
APPLIED_V4=$n APPLIED_V4=$n
} }
@@ -214,6 +329,7 @@ apply_iptables_only() {
iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP
n=$((n + 1)) n=$((n + 1))
done done
KERNEL_METHOD=iptables
APPLIED_V4=$n APPLIED_V4=$n
} }
@@ -227,9 +343,11 @@ clear_block() {
iptables) iptables -S INPUT | grep -i evobgp | sed 's/^-A /-D /' | while read -r line; do iptables $line 2>/dev/null || true; done ;; iptables) iptables -S INPUT | grep -i evobgp | sed 's/^-A /-D /' | while read -r line; do iptables $line 2>/dev/null || true; done ;;
esac esac
APPLIED_V4=0 APPLIED_V4=0
KERNEL_METHOD="${BACKEND:-auto}"
} }
APPLIED_V4=0 APPLIED_V4=0
KERNEL_METHOD=""
if [[ "${TOTAL:-0}" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then if [[ "${TOTAL:-0}" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
clear_block clear_block
log "cleared blocklist (api total=${TOTAL:-0}) backend=$BACKEND" log "cleared blocklist (api total=${TOTAL:-0}) backend=$BACKEND"
@@ -244,14 +362,4 @@ else
fi fi
echo "$HASH" >"$HASH_FILE" echo "$HASH" >"$HASH_FILE"
send_client_reports
REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"source":"cp"}' "${TOTAL:-0}" "${APPLIED_V4:-0}")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$REPORT" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true
+13 -8
View File
@@ -450,13 +450,15 @@ func (s *Server) handleFirewallApplyReport(w http.ResponseWriter, r *http.Reques
return return
} }
var body struct { var body struct {
Status string `json:"status"` Status string `json:"status"`
Error string `json:"error"` Error string `json:"error"`
PrefixCount int `json:"prefix_count"` PrefixCount int `json:"prefix_count"`
IPCount int `json:"ip_count"` IPCount int `json:"ip_count"`
Version string `json:"version"` PacketsDropped int64 `json:"packets_dropped"`
KernelMethod string `json:"kernel_method"` PacketsAccepted int64 `json:"packets_accepted"`
Source string `json:"source"` Version string `json:"version"`
KernelMethod string `json:"kernel_method"`
Source string `json:"source"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body") writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
@@ -466,7 +468,10 @@ func (s *Server) handleFirewallApplyReport(w http.ResponseWriter, r *http.Reques
if src == "" { if src == "" {
src = "cp" src = "cp"
} }
_ = s.store.TouchFirewallClientLastApply(a.APIKeyID, src, body.Status, body.Error, body.PrefixCount, body.IPCount) _ = s.store.TouchFirewallClientLastApply(
a.APIKeyID, src, body.Status, body.Error,
body.PrefixCount, body.IPCount, body.PacketsDropped, body.PacketsAccepted,
)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
} }
+90
View File
@@ -2,6 +2,7 @@ package httpapi
import ( import (
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -94,6 +95,27 @@ func TestFirewallEnrollAndBlocklist(t *testing.T) {
if total, _ := bl["total"].(float64); total != 0 { if total, _ := bl["total"].(float64); total != 0 {
t.Fatalf("accept-only want empty blocklist, total=%v", total) t.Fatalf("accept-only want empty blocklist, total=%v", total)
} }
reportBody := `{"status":"ok","prefix_count":0,"ip_count":0,"packets_dropped":42,"packets_accepted":1000,"source":"cp","kernel_method":"nft"}`
reqReport, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/apply-report", strings.NewReader(reportBody))
reqReport.Header.Set("Authorization", "Bearer "+tok)
reqReport.Header.Set("Content-Type", "application/json")
respReport, err := client.Do(reqReport)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respReport.Body.Close() }()
if respReport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respReport.Body)
t.Fatalf("apply-report status=%d body=%s", respReport.StatusCode, b)
}
gotClient, err := srv.Store().GetFirewallClient(tenant, clientID)
if err != nil {
t.Fatal(err)
}
if gotClient.LastApplyPacketsDropped != 42 || gotClient.LastApplyPacketsAccepted != 1000 {
t.Fatalf("packet stats dropped=%d accepted=%d", gotClient.LastApplyPacketsDropped, gotClient.LastApplyPacketsAccepted)
}
} }
func TestFirewallEnrollBadSeed(t *testing.T) { func TestFirewallEnrollBadSeed(t *testing.T) {
@@ -205,6 +227,74 @@ func TestFirewallInstallContext(t *testing.T) {
} }
} }
func TestFirewallDeletePendingClient(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
tok := "evobgp_fw_revoketest123456789012345678901"
enrollBody := `{"name":"reject-me","hostname":"test.local","client_token":"` + tok + `","client_version":"test/1"}`
reqEnroll, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/enroll", strings.NewReader(enrollBody))
reqEnroll.Header.Set("Content-Type", "application/json")
reqEnroll.Header.Set("X-EvoBGP-Seed", testBundleSeed)
respEnroll, err := client.Do(reqEnroll)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respEnroll.Body.Close() }()
if respEnroll.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(respEnroll.Body)
t.Fatalf("enroll status=%d body=%s", respEnroll.StatusCode, b)
}
var enroll map[string]any
if err := json.NewDecoder(respEnroll.Body).Decode(&enroll); err != nil {
t.Fatal(err)
}
clientID, _ := enroll["client_id"].(string)
if clientID == "" {
t.Fatal("missing client_id")
}
reqDelete, _ := http.NewRequest(http.MethodDelete, ts.URL+"/v1/firewall/clients/"+clientID, nil)
reqDelete.Header.Set("Authorization", "Bearer opkey")
respDelete, err := client.Do(reqDelete)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respDelete.Body.Close() }()
if respDelete.StatusCode != http.StatusNoContent {
b, _ := io.ReadAll(respDelete.Body)
t.Fatalf("delete status=%d body=%s", respDelete.StatusCode, b)
}
_, err = srv.Store().GetFirewallClient(tenant, clientID)
if err == nil {
t.Fatal("client should be deleted")
}
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("delete err=%v", err)
}
reqBlock, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/blocklist", nil)
reqBlock.Header.Set("Authorization", "Bearer "+tok)
respBlock, err := client.Do(reqBlock)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respBlock.Body.Close() }()
if respBlock.StatusCode != http.StatusUnauthorized {
t.Fatalf("deleted blocklist want 401 got %d", respBlock.StatusCode)
}
}
func TestFirewallTokenHashMatchesAuthkey(t *testing.T) { func TestFirewallTokenHashMatchesAuthkey(t *testing.T) {
tok := "evobgp_fw_sample" tok := "evobgp_fw_sample"
h := authkey.HashToken(tok) h := authkey.HashToken(tok)
+20 -9
View File
@@ -17,7 +17,9 @@ const firewallClientSelectCols = `
id, name, COALESCE(hostname, ''), token_prefix, status, id, name, COALESCE(hostname, ''), token_prefix, status,
last_seen_at, COALESCE(last_seen_at_source, ''), COALESCE(last_seen_ip, ''), last_seen_at, COALESCE(last_seen_at_source, ''), COALESCE(last_seen_ip, ''),
last_apply_at, COALESCE(last_apply_status, ''), COALESCE(last_apply_error, ''), last_apply_at, COALESCE(last_apply_status, ''), COALESCE(last_apply_error, ''),
COALESCE(last_apply_prefix_count, 0), COALESCE(last_apply_ip_count, 0), COALESCE(last_apply_source, ''), 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`
func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) { func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) {
@@ -167,12 +169,13 @@ func (p *Postgres) TouchFirewallClientLastSeen(id, source, clientIP, clientVersi
return err return err
} }
func (p *Postgres) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error { func (p *Postgres) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int, packetsDropped, packetsAccepted int64) error {
ctx := context.Background() ctx := context.Background()
_, err := p.pool.Exec(ctx, ` _, err := p.pool.Exec(ctx, `
UPDATE firewall_client SET last_apply_at=now(), last_apply_source=$2, last_apply_status=$3, UPDATE firewall_client SET last_apply_at=now(), last_apply_source=$2, last_apply_status=$3,
last_apply_error=$4, last_apply_prefix_count=$5, last_apply_ip_count=$6 last_apply_error=$4, last_apply_prefix_count=$5, last_apply_ip_count=$6,
WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(status), strings.TrimSpace(errMsg), prefixCount, ipCount) last_apply_packets_dropped=$7, last_apply_packets_accepted=$8
WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(status), strings.TrimSpace(errMsg), prefixCount, ipCount, packetsDropped, packetsAccepted)
return err return err
} }
@@ -435,16 +438,17 @@ func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient,
var approvedBy *string var approvedBy *string
var lastSeen, lastApply, approved, revoked *time.Time var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int var prefixCount, ipCount *int
var packetsDropped, packetsAccepted *int64
if err := scan( if err := scan(
&c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status, &c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP, &lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError, &lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &c.LastApplySource, &prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked, &c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
} }
func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) { func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
@@ -452,19 +456,20 @@ func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
var approvedBy *string var approvedBy *string
var lastSeen, lastApply, approved, revoked *time.Time var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int var prefixCount, ipCount *int
var packetsDropped, packetsAccepted *int64
if err := scan( if err := scan(
&c.TenantID, &c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status, &c.TenantID, &c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP, &lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError, &lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &c.LastApplySource, &prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked, &c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
} }
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int) *store.FirewallClient { func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
c.LastSeenAt = lastSeen c.LastSeenAt = lastSeen
c.LastApplyAt = lastApply c.LastApplyAt = lastApply
c.ApprovedAt = approved c.ApprovedAt = approved
@@ -478,6 +483,12 @@ func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, appr
if ipCount != nil { if ipCount != nil {
c.LastApplyIPCount = *ipCount c.LastApplyIPCount = *ipCount
} }
if packetsDropped != nil {
c.LastApplyPacketsDropped = *packetsDropped
}
if packetsAccepted != nil {
c.LastApplyPacketsAccepted = *packetsAccepted
}
return c return c
} }
+1 -1
View File
@@ -142,7 +142,7 @@ type Backend interface {
DeleteFirewallClient(tenantID, id string) error DeleteFirewallClient(tenantID, id string) error
LookupFirewallClientByTokenHash(hash []byte) (*FirewallClient, error) LookupFirewallClientByTokenHash(hash []byte) (*FirewallClient, error)
TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error
TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int, packetsDropped, packetsAccepted int64) error
ListActiveFirewallClientHashes() ([]FirewallClientAuthRow, error) ListActiveFirewallClientHashes() ([]FirewallClientAuthRow, error)
ListApprovedFirewallClientsForReplication(tenantID string) ([]FirewallClientReplicationRow, error) ListApprovedFirewallClientsForReplication(tenantID string) ([]FirewallClientReplicationRow, error)
+22 -20
View File
@@ -7,26 +7,28 @@ import (
// FirewallClient is a Linux blocklist sync client enrolled via seed. // FirewallClient is a Linux blocklist sync client enrolled via seed.
type FirewallClient struct { type FirewallClient struct {
ID string `json:"id"` ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"` TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Hostname string `json:"hostname,omitempty"` Hostname string `json:"hostname,omitempty"`
TokenPrefix string `json:"token_prefix"` TokenPrefix string `json:"token_prefix"`
Status string `json:"status"` Status string `json:"status"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"` LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
LastSeenAtSource string `json:"last_seen_at_source,omitempty"` LastSeenAtSource string `json:"last_seen_at_source,omitempty"`
LastSeenIP string `json:"last_seen_ip,omitempty"` LastSeenIP string `json:"last_seen_ip,omitempty"`
LastApplyAt *time.Time `json:"last_apply_at,omitempty"` LastApplyAt *time.Time `json:"last_apply_at,omitempty"`
LastApplyStatus string `json:"last_apply_status,omitempty"` LastApplyStatus string `json:"last_apply_status,omitempty"`
LastApplyError string `json:"last_apply_error,omitempty"` LastApplyError string `json:"last_apply_error,omitempty"`
LastApplyPrefixCount int `json:"last_apply_prefix_count,omitempty"` LastApplyPrefixCount int `json:"last_apply_prefix_count,omitempty"`
LastApplyIPCount int `json:"last_apply_ip_count,omitempty"` LastApplyIPCount int `json:"last_apply_ip_count,omitempty"`
LastApplySource string `json:"last_apply_source,omitempty"` LastApplyPacketsDropped int64 `json:"last_apply_packets_dropped,omitempty"`
ClientVersion string `json:"client_version,omitempty"` LastApplyPacketsAccepted int64 `json:"last_apply_packets_accepted,omitempty"`
CreatedAt time.Time `json:"created_at"` LastApplySource string `json:"last_apply_source,omitempty"`
ApprovedAt *time.Time `json:"approved_at,omitempty"` ClientVersion string `json:"client_version,omitempty"`
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"` CreatedAt time.Time `json:"created_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"` ApprovedAt *time.Time `json:"approved_at,omitempty"`
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
} }
// FirewallClientCreate is input for enroll (token hash supplied by caller). // FirewallClientCreate is input for enroll (token hash supplied by caller).
+3 -1
View File
@@ -171,7 +171,7 @@ func (m *Memory) TouchFirewallClientLastSeen(id, source, clientIP, clientVersion
return nil return nil
} }
func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error { func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int, packetsDropped, packetsAccepted int64) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
rec, ok := m.firewallClients[id] rec, ok := m.firewallClients[id]
@@ -185,6 +185,8 @@ func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string,
rec.LastApplyError = strings.TrimSpace(errMsg) rec.LastApplyError = strings.TrimSpace(errMsg)
rec.LastApplyPrefixCount = prefixCount rec.LastApplyPrefixCount = prefixCount
rec.LastApplyIPCount = ipCount rec.LastApplyIPCount = ipCount
rec.LastApplyPacketsDropped = packetsDropped
rec.LastApplyPacketsAccepted = packetsAccepted
return nil return nil
} }
@@ -0,0 +1,3 @@
ALTER TABLE firewall_client
DROP COLUMN IF EXISTS last_apply_packets_dropped,
DROP COLUMN IF EXISTS last_apply_packets_accepted;
@@ -0,0 +1,3 @@
ALTER TABLE firewall_client
ADD COLUMN last_apply_packets_dropped BIGINT NOT NULL DEFAULT 0,
ADD COLUMN last_apply_packets_accepted BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,2 @@
ALTER TABLE firewall_client DROP COLUMN last_apply_packets_dropped;
ALTER TABLE firewall_client DROP COLUMN last_apply_packets_accepted;
@@ -0,0 +1,2 @@
ALTER TABLE firewall_client ADD COLUMN last_apply_packets_dropped INTEGER NOT NULL DEFAULT 0;
ALTER TABLE firewall_client ADD COLUMN last_apply_packets_accepted INTEGER NOT NULL DEFAULT 0;
+121 -13
View File
@@ -147,8 +147,119 @@ if [[ -z "${TOTAL// }" ]]; then
TOTAL=${#PREFIXES[@]} TOTAL=${#PREFIXES[@]}
fi fi
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
KERNEL_METHOD=""
APPLIED_V4=0
count_ipv4_prefixes() {
local n=0 p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
n=$((n + 1))
done
APPLIED_V4=$n
}
nft_rule_packets() {
local line=$1
if [[ "$line" =~ counter[[:space:]]+packets[[:space:]]+([0-9]+) ]]; then
echo "${BASH_REMATCH[1]}"
else
echo 0
fi
}
ensure_nft_counters() {
local table=inet name=evobgp_blocklist
nft list chain "$table" "$name" input >/dev/null 2>&1 || return 0
local drop_line
drop_line=$(nft -a list chain "$table" "$name" input 2>/dev/null | grep 'ip saddr @v4' | grep drop | head -1 || true)
if [[ -n "$drop_line" && "$drop_line" != *counter* ]]; then
local handle
handle=$(echo "$drop_line" | sed -n 's/.*# handle \([0-9]\+\).*/\1/p')
if [[ -n "$handle" ]]; then
nft delete rule "$table" "$name" input handle "$handle" 2>>"$LOG_FILE" || true
drop_line=""
fi
fi
if [[ -z "$drop_line" ]]; then
nft add rule "$table" "$name" input ip saddr @v4 counter drop
fi
if ! nft list chain "$table" "$name" input 2>/dev/null | grep -qE '[[:space:]]counter[[:space:]]+accept'; then
nft add rule "$table" "$name" input counter accept
fi
}
collect_nft_packet_stats() {
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
local line pkts
while IFS= read -r line; do
if [[ "$line" == *"ip saddr @v4"* && "$line" == *drop* ]]; then
pkts=$(nft_rule_packets "$line")
[[ -n "$pkts" ]] && PACKETS_DROPPED=$pkts
elif [[ "$line" == *counter* && "$line" == *accept* && "$line" != *@v4* ]]; then
pkts=$(nft_rule_packets "$line")
[[ -n "$pkts" ]] && PACKETS_ACCEPTED=$pkts
fi
done < <(nft list chain inet evobgp_blocklist input 2>/dev/null || true)
}
collect_ipset_packet_stats() {
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
local pkts
pkts=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/match-set evobgp_blocklist_v4/ {print $1; exit}')
[[ "$pkts" =~ ^[0-9]+$ ]] && PACKETS_DROPPED=$pkts
}
collect_packet_stats() {
case "${KERNEL_METHOD:-$BACKEND}" in
nft)
ensure_nft_counters
collect_nft_packet_stats
;;
ipset)
collect_ipset_packet_stats
;;
iptables)
PACKETS_DROPPED=$(iptables -L INPUT -v -n -x 2>/dev/null | awk '/DROP/ {s+=$1} END {print s+0}')
PACKETS_ACCEPTED=0
;;
*)
if command -v nft >/dev/null 2>&1 && nft list chain inet evobgp_blocklist input >/dev/null 2>&1; then
KERNEL_METHOD=nft
ensure_nft_counters
collect_nft_packet_stats
elif iptables -L INPUT -v -n -x 2>/dev/null | grep -q 'evobgp_blocklist_v4'; then
KERNEL_METHOD=ipset
collect_ipset_packet_stats
fi
;;
esac
}
send_client_reports() {
collect_packet_stats
local km="${KERNEL_METHOD:-$BACKEND}"
local report
report=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"packets_dropped":%s,"packets_accepted":%s,"source":"cp","kernel_method":"%s"}' \
"${TOTAL:-0}" "${APPLIED_V4:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "$km")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$report" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true
}
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
log "unchanged hash $HASH — skip kernel apply" count_ipv4_prefixes
log "unchanged hash $HASH — skip kernel apply (ipv4=${APPLIED_V4})"
send_client_reports
exit 0 exit 0
fi fi
@@ -184,8 +295,11 @@ apply_nft() {
nft list chain "$table" "$name" input >/dev/null 2>&1 || { nft list chain "$table" "$name" input >/dev/null 2>&1 || {
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }' nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
nft add rule "$table" "$name" input ip saddr @v4 drop nft add rule "$table" "$name" input ip saddr @v4 counter drop
nft add rule "$table" "$name" input counter accept
} }
ensure_nft_counters
KERNEL_METHOD=nft
APPLIED_V4=${#v4[@]} APPLIED_V4=${#v4[@]}
} }
@@ -202,6 +316,7 @@ apply_ipset() {
done done
iptables -C INPUT -m set --match-set "$set" src -j DROP 2>/dev/null || \ iptables -C INPUT -m set --match-set "$set" src -j DROP 2>/dev/null || \
iptables -I INPUT -m set --match-set "$set" src -j DROP iptables -I INPUT -m set --match-set "$set" src -j DROP
KERNEL_METHOD=ipset
APPLIED_V4=$n APPLIED_V4=$n
} }
@@ -214,6 +329,7 @@ apply_iptables_only() {
iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP
n=$((n + 1)) n=$((n + 1))
done done
KERNEL_METHOD=iptables
APPLIED_V4=$n APPLIED_V4=$n
} }
@@ -227,9 +343,11 @@ clear_block() {
iptables) iptables -S INPUT | grep -i evobgp | sed 's/^-A /-D /' | while read -r line; do iptables $line 2>/dev/null || true; done ;; iptables) iptables -S INPUT | grep -i evobgp | sed 's/^-A /-D /' | while read -r line; do iptables $line 2>/dev/null || true; done ;;
esac esac
APPLIED_V4=0 APPLIED_V4=0
KERNEL_METHOD="${BACKEND:-auto}"
} }
APPLIED_V4=0 APPLIED_V4=0
KERNEL_METHOD=""
if [[ "${TOTAL:-0}" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then if [[ "${TOTAL:-0}" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
clear_block clear_block
log "cleared blocklist (api total=${TOTAL:-0}) backend=$BACKEND" log "cleared blocklist (api total=${TOTAL:-0}) backend=$BACKEND"
@@ -244,14 +362,4 @@ else
fi fi
echo "$HASH" >"$HASH_FILE" echo "$HASH" >"$HASH_FILE"
send_client_reports
REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"source":"cp"}' "${TOTAL:-0}" "${APPLIED_V4:-0}")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$REPORT" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true