feat(api, web): add Linux nft destination port hits for blocked IPs
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m59s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

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 <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-08-11 00:33:03 +07:00
co-authored by Cursor
parent 402182195f
commit c5069fbdaf
16 changed files with 897 additions and 30 deletions
@@ -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>