feat(api, web): add Linux nft destination port hits for blocked IPs
Track tcp/udp dports via deny_port_hits, expose aggregate and per-IP ports in UI; install-link re-run refreshes nft rules. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -26,11 +26,18 @@ import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
* · https://reui.io/preview/base/empty-state-12
|
||||
*/
|
||||
|
||||
export type BlockedIpPort = {
|
||||
port: number
|
||||
protocol: string
|
||||
packets: number
|
||||
}
|
||||
|
||||
export type BlockedIpRow = {
|
||||
ip: string
|
||||
packets: number
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
ports?: BlockedIpPort[]
|
||||
}
|
||||
|
||||
type AgentBlockedIpsProps = {
|
||||
@@ -54,17 +61,25 @@ function formatSeen(iso: string): string {
|
||||
return seenFmt.format(t)
|
||||
}
|
||||
|
||||
function formatPorts(ports: BlockedIpPort[] | undefined): string {
|
||||
if (!ports?.length) return '—'
|
||||
return ports
|
||||
.map((p) => `${p.protocol}/${p.port}`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
const isMikrotik = platform === 'mikrotik'
|
||||
const showPorts = !isMikrotik
|
||||
const q = useQuery(agentBlockedIpsQueryOptions(agentId))
|
||||
|
||||
const packetsTitle = isMikrotik ? 'Hits' : 'Packets'
|
||||
const description = isMikrotik
|
||||
? 'Src /32 из EVOFW_HITS (add-src при deny, timeout 1h). Hits — входы в список (не каждый sync); Last seen обновляется, пока IP в hits.'
|
||||
: 'Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets.'
|
||||
: 'Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets. Ports — top-5 dport (nft).'
|
||||
|
||||
const columns = useMemo<ColumnDef<BlockedIpRow>[]>(
|
||||
() => [
|
||||
const columns = useMemo<ColumnDef<BlockedIpRow>[]>(() => {
|
||||
const cols: ColumnDef<BlockedIpRow>[] = [
|
||||
{
|
||||
accessorKey: 'ip',
|
||||
id: 'ip',
|
||||
@@ -89,22 +104,37 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
),
|
||||
meta: { headerTitle: packetsTitle },
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
id: 'last_seen_at',
|
||||
]
|
||||
if (showPorts) {
|
||||
cols.push({
|
||||
id: 'ports',
|
||||
accessorFn: (row) => formatPorts(row.ports),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Last seen" />
|
||||
<DataGridColumnHeader column={column} title="Ports" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatSeen(row.original.last_seen_at)}
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
{formatPorts(row.original.ports)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Last seen' },
|
||||
},
|
||||
],
|
||||
[packetsTitle],
|
||||
)
|
||||
meta: { headerTitle: 'Ports' },
|
||||
})
|
||||
}
|
||||
cols.push({
|
||||
accessorKey: 'last_seen_at',
|
||||
id: 'last_seen_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Last seen" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatSeen(row.original.last_seen_at)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Last seen' },
|
||||
})
|
||||
return cols
|
||||
}, [packetsTitle, showPorts])
|
||||
|
||||
const data = q.data?.items ?? []
|
||||
const table = useReactTable({
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table'
|
||||
import { NetworkIcon } from 'lucide-react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { agentBlockedPortsQueryOptions } from '@/queries'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
/**
|
||||
* Aggregate destination ports hit by denied sources (Linux nft).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* · https://reui.io/preview/base/empty-state-12
|
||||
*/
|
||||
|
||||
export type BlockedPortRow = {
|
||||
port: number
|
||||
protocol: string
|
||||
packets: number
|
||||
last_seen_at: string
|
||||
}
|
||||
|
||||
type AgentBlockedPortsProps = {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
const packetFmt = new Intl.NumberFormat('ru-RU')
|
||||
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
function formatSeen(iso: string): string {
|
||||
const t = Date.parse(iso)
|
||||
if (Number.isNaN(t)) return '—'
|
||||
return seenFmt.format(t)
|
||||
}
|
||||
|
||||
export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
||||
const q = useQuery(agentBlockedPortsQueryOptions(agentId))
|
||||
|
||||
const columns = useMemo<ColumnDef<BlockedPortRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'port',
|
||||
id: 'port',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Port" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{row.original.port}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Port' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'protocol',
|
||||
id: 'protocol',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Proto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs uppercase">
|
||||
{row.original.protocol}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Proto' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'packets',
|
||||
id: 'packets',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Packets" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{packetFmt.format(row.original.packets)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Packets' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
id: 'last_seen_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Last seen" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatSeen(row.original.last_seen_at)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Last seen' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const data = q.data?.items ?? []
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (r) => `${r.protocol}/${r.port}`,
|
||||
})
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Top ports</FrameTitle>
|
||||
<FrameDescription>
|
||||
Destination ports (tcp/udp), в которые слали запросы blocked IP. nft
|
||||
dynamic set deny_port_hits.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
{q.isLoading ? (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-2/3" />
|
||||
</div>
|
||||
) : q.isError ? (
|
||||
<EmptyState
|
||||
icon={NetworkIcon}
|
||||
title="Не удалось загрузить"
|
||||
description={q.error?.message ?? 'Ошибка API'}
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
) : data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={NetworkIcon}
|
||||
title="Пока нет port hit’ов"
|
||||
description="Нужен nft + deny_port_hits. После drop с deny появятся tcp/udp dport. Re-run install-ссылки обновляет правила."
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
|
||||
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
||||
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
||||
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
|
||||
import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
@@ -104,6 +105,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
|
||||
void qc.invalidateQueries({ queryKey: ['stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
@@ -320,6 +322,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
|
||||
{a.platform === 'linux' ? (
|
||||
<AgentBlockedPorts agentId={agentId} />
|
||||
) : null}
|
||||
|
||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
|
||||
Reference in New Issue
Block a user