feat(api, web): implement port ACL and host firewall snapshot features
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m43s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added support for managing desired L4 port ACL rules for Linux agents, allowing for open/close actions on specified ports.
- Introduced a new endpoint for CRUD operations on port rules, enhancing the API's capabilities for agent management.
- Implemented functionality to collect and report host firewall snapshots, capturing observed rules and listeners for better monitoring.
- Updated the agent detail view to include tabs for managing port ACLs and viewing host firewall data, improving user experience.
- Enhanced documentation to reflect the new features and API changes, ensuring clarity for users and developers.

These changes significantly improve the management and visibility of firewall rules and port access control for agents.
This commit is contained in:
Denozordec
2026-08-11 15:08:00 +07:00
parent c5069fbdaf
commit 43f5ac2525
22 changed files with 2853 additions and 17 deletions
@@ -0,0 +1,329 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
} from '@tanstack/react-table'
import { ShieldIcon } 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 { Badge } from '@/components/reui/badge'
import { EmptyState } from '@/components/empty-state'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import {
agentHostFirewallQueryOptions,
type HostFwRuleDto,
type HostListenerDto,
} from '@/queries'
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import { TabsContent } from '@evofw/ui/components/tabs'
/**
* Observed host firewall + listeners (Linux).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* · https://reui.io/preview/base/empty-state-12
*/
type AgentHostFirewallProps = {
agentId: string
}
export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
const q = useQuery(agentHostFirewallQueryOptions(agentId))
const [tab, setTab] = useState('rules')
const [ownership, setOwnership] = useState<'all' | 'evofw' | 'foreign'>('all')
const [backend, setBackend] = useState<string>('all')
const rules = useMemo(() => {
let items = q.data?.rules ?? []
if (ownership !== 'all') {
items = items.filter((r) => r.ownership === ownership)
}
if (backend !== 'all') {
items = items.filter((r) => r.backend === backend)
}
return items
}, [q.data?.rules, ownership, backend])
const listeners = q.data?.listeners ?? []
const backends = useMemo(() => {
const s = new Set((q.data?.rules ?? []).map((r) => r.backend))
return Array.from(s).sort()
}, [q.data?.rules])
const ruleCols = useMemo<ColumnDef<HostFwRuleDto>[]>(
() => [
{
id: 'ownership',
accessorKey: 'ownership',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Owner" />
),
cell: ({ row }) =>
row.original.ownership === 'evofw' ? (
<Badge variant="success" size="sm">
EvoFW
</Badge>
) : (
<Badge variant="secondary" size="sm">
foreign
</Badge>
),
meta: { headerTitle: 'Owner' },
},
{
accessorKey: 'backend',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Backend" />
),
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.backend}</span>
),
},
{
id: 'chain',
accessorFn: (r) => r.chain || r.table || '—',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Chain" />
),
cell: ({ row }) => (
<span className="font-mono text-muted-foreground text-xs">
{row.original.chain || row.original.table || '—'}
</span>
),
},
{
accessorKey: 'action',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Action" />
),
cell: ({ row }) => (
<span className="text-xs">{row.original.action || '—'}</span>
),
},
{
id: 'ports',
accessorFn: (r) =>
[r.protocol, r.dport].filter(Boolean).join('/') || '—',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Proto/Port" />
),
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums">
{[row.original.protocol, row.original.dport]
.filter(Boolean)
.join('/') || '—'}
</span>
),
},
{
accessorKey: 'saddr',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Src" />
),
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.saddr || '—'}
</span>
),
},
{
accessorKey: 'raw',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Raw" />
),
cell: ({ row }) => (
<span
className="text-muted-foreground block max-w-[280px] truncate font-mono text-[11px]"
title={row.original.raw}
>
{row.original.raw}
</span>
),
},
],
[],
)
const listenerCols = useMemo<ColumnDef<HostListenerDto>[]>(
() => [
{
accessorKey: 'protocol',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Proto" />
),
cell: ({ row }) => (
<span className="font-mono text-xs uppercase">
{row.original.protocol}
</span>
),
},
{
accessorKey: 'port',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Port" />
),
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums">
{row.original.port}
</span>
),
},
{
accessorKey: 'address',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Address" />
),
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.address}</span>
),
},
{
accessorKey: 'process',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Process" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
{row.original.process || '—'}
</span>
),
},
],
[],
)
const rulesTable = useReactTable({
data: rules,
columns: ruleCols,
getCoreRowModel: getCoreRowModel(),
getRowId: (r, i) => `${r.backend}-${r.chain}-${i}-${r.raw.slice(0, 40)}`,
})
const listenersTable = useReactTable({
data: listeners,
columns: listenerCols,
getCoreRowModel: getCoreRowModel(),
getRowId: (r, i) => `${r.protocol}-${r.address}-${r.port}-${i}`,
})
return (
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Host firewall</FrameTitle>
<FrameDescription>
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
{q.data?.collected_at
? ` Обновлено: ${new Date(q.data.collected_at).toLocaleString('ru-RU')}`
: ' Пока нет снимка — дождитесь sync агента.'}
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3 p-4">
<CountedLineTabs
value={tab}
onValueChange={setTab}
tabs={[
{ id: 'rules', label: 'Rules', count: rules.length },
{ id: 'listeners', label: 'Listeners', count: listeners.length },
]}
>
<TabsContent value="rules" className="mt-3 flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<Select
value={ownership}
onValueChange={(v) => {
if (v) setOwnership(v as typeof ownership)
}}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Owner" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All owners</SelectItem>
<SelectItem value="evofw">EvoFW</SelectItem>
<SelectItem value="foreign">Foreign</SelectItem>
</SelectContent>
</Select>
<Select
value={backend}
onValueChange={(v) => {
if (v) setBackend(v)
}}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Backend" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All backends</SelectItem>
{backends.map((b) => (
<SelectItem key={b} value={b}>
{b}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{q.isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : rules.length === 0 ? (
<EmptyState
icon={ShieldIcon}
title="Нет правил в снимке"
description="После sync Linux-агент пришлёт host_firewall."
centered={false}
className="py-6"
/>
) : (
<DataGrid
table={rulesTable}
recordCount={rules.length}
tableLayout={{ dense: true }}
>
<DataGridTable />
</DataGrid>
)}
</TabsContent>
<TabsContent value="listeners" className="mt-3">
{q.isLoading ? (
<Skeleton className="h-8 w-full" />
) : listeners.length === 0 ? (
<EmptyState
icon={ShieldIcon}
title="Нет listeners"
description="ss -lntu не вернул сокеты или снимок пуст."
centered={false}
className="py-6"
/>
) : (
<DataGrid
table={listenersTable}
recordCount={listeners.length}
tableLayout={{ dense: true }}
>
<DataGridTable />
</DataGrid>
)}
</TabsContent>
</CountedLineTabs>
</FramePanel>
</Frame>
)
}