feat(api, web): implement port ACL and host firewall snapshot features
- 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:
@@ -35,6 +35,9 @@ 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 { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
|
||||
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
@@ -47,6 +50,7 @@ import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { TabsContent } from '@evofw/ui/components/tabs'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -75,6 +79,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
const installRef = useRef<HTMLDivElement>(null)
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [fwTab, setFwTab] = useState('host')
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -323,10 +328,37 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
/>
|
||||
|
||||
{a.platform === 'linux' ? (
|
||||
<AgentBlockedPorts agentId={agentId} />
|
||||
) : null}
|
||||
|
||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||
<div className="flex flex-col gap-3">
|
||||
<CountedLineTabs
|
||||
value={fwTab}
|
||||
onValueChange={setFwTab}
|
||||
tabs={[
|
||||
{ id: 'host', label: 'Host firewall' },
|
||||
{ id: 'acl', label: 'Port ACL' },
|
||||
{ id: 'hits', label: 'Blocked' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="host" className="mt-3">
|
||||
<AgentHostFirewall agentId={agentId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="acl" className="mt-3">
|
||||
<AgentPortAcl agentId={agentId} />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="hits"
|
||||
className="mt-3 flex flex-col gap-4"
|
||||
>
|
||||
<AgentBlockedPorts agentId={agentId} />
|
||||
<AgentBlockedIps
|
||||
agentId={agentId}
|
||||
platform={a.platform}
|
||||
/>
|
||||
</TabsContent>
|
||||
</CountedLineTabs>
|
||||
</div>
|
||||
) : (
|
||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||
)}
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
import { NetworkIcon, PencilIcon, PlusIcon, Trash2Icon } 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 {
|
||||
agentPortRulesQueryOptions,
|
||||
listsQueryOptions,
|
||||
policySetsQueryOptions,
|
||||
type AgentPortRuleDto,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Switch } from '@evofw/ui/components/switch'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||
|
||||
/**
|
||||
* Desired Port ACL for Linux agent.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* · https://reui.io/preview/base/sheet-8
|
||||
*/
|
||||
|
||||
type AgentPortAclProps = {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
action: 'open' | 'close'
|
||||
protocol: 'tcp' | 'udp' | 'both'
|
||||
port_start: string
|
||||
port_end: string
|
||||
src_kind: 'all' | 'cidr' | 'list'
|
||||
src_cidr: string
|
||||
list_id: string
|
||||
comment: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
action: 'open',
|
||||
protocol: 'tcp',
|
||||
port_start: '',
|
||||
port_end: '',
|
||||
src_kind: 'all',
|
||||
src_cidr: '',
|
||||
list_id: '',
|
||||
comment: '',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
function formatPorts(r: AgentPortRuleDto): string {
|
||||
return r.port_start === r.port_end
|
||||
? String(r.port_start)
|
||||
: `${r.port_start}-${r.port_end}`
|
||||
}
|
||||
|
||||
function formatSrc(r: AgentPortRuleDto): string {
|
||||
if (r.src_kind === 'all') return 'all'
|
||||
if (r.src_kind === 'cidr') return r.src_cidr || '—'
|
||||
return r.list_name || r.list_id || 'list'
|
||||
}
|
||||
|
||||
export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
const qc = useQueryClient()
|
||||
const q = useQuery(agentPortRulesQueryOptions(agentId))
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
const setsQ = useQuery(policySetsQueryOptions())
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AgentPortRuleDto | null>(null)
|
||||
const [form, setForm] = useState<FormState>(emptyForm)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [impFrom, setImpFrom] = useState<'list' | 'set'>('list')
|
||||
const [impListId, setImpListId] = useState('')
|
||||
const [impSetId, setImpSetId] = useState('')
|
||||
const [impAction, setImpAction] = useState<'open' | 'close'>('open')
|
||||
const [impProtocol, setImpProtocol] = useState<'tcp' | 'udp' | 'both'>('tcp')
|
||||
const [impPorts, setImpPorts] = useState('22,80,443')
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'port-rules'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'preview'] })
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const portStart = Number(form.port_start)
|
||||
const portEnd = form.port_end ? Number(form.port_end) : portStart
|
||||
const body = {
|
||||
action: form.action,
|
||||
protocol: form.protocol,
|
||||
port_start: portStart,
|
||||
port_end: portEnd,
|
||||
src_kind: form.src_kind,
|
||||
src_cidr: form.src_kind === 'cidr' ? form.src_cidr : undefined,
|
||||
list_id: form.src_kind === 'list' ? form.list_id : undefined,
|
||||
enabled: form.enabled,
|
||||
comment: form.comment || undefined,
|
||||
}
|
||||
if (editing) {
|
||||
return apiFetch(`/api/v1/agents/${agentId}/port-rules/${editing.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
return apiFetch(`/api/v1/agents/${agentId}/port-rules`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(editing ? 'Правило обновлено' : 'Правило создано')
|
||||
setFormOpen(false)
|
||||
setEditing(null)
|
||||
setForm(emptyForm())
|
||||
invalidate()
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: (row: AgentPortRuleDto) =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/port-rules/${row.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ enabled: !row.enabled }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Состояние обновлено')
|
||||
invalidate()
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/port-rules/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Правило удалено')
|
||||
invalidate()
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const doImport = useMutation({
|
||||
mutationFn: async () => {
|
||||
const ports = impPorts
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => {
|
||||
if (s.includes('-')) {
|
||||
const [a, b] = s.split('-')
|
||||
return {
|
||||
port_start: Number(a),
|
||||
port_end: Number(b),
|
||||
}
|
||||
}
|
||||
return { port_start: Number(s) }
|
||||
})
|
||||
return apiFetch(`/api/v1/agents/${agentId}/port-rules/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
from: impFrom,
|
||||
list_id: impFrom === 'list' ? impListId : undefined,
|
||||
set_id: impFrom === 'set' ? impSetId : undefined,
|
||||
action: impAction,
|
||||
protocol: impProtocol,
|
||||
ports,
|
||||
}),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Импорт выполнен')
|
||||
setImportOpen(false)
|
||||
invalidate()
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setForm(emptyForm())
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: AgentPortRuleDto) => {
|
||||
setEditing(row)
|
||||
setForm({
|
||||
action: row.action,
|
||||
protocol: row.protocol,
|
||||
port_start: String(row.port_start),
|
||||
port_end:
|
||||
row.port_end !== row.port_start ? String(row.port_end) : '',
|
||||
src_kind: row.src_kind,
|
||||
src_cidr: row.src_cidr || '',
|
||||
list_id: row.list_id || '',
|
||||
comment: row.comment || '',
|
||||
enabled: row.enabled,
|
||||
})
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const columns = useMemo<ColumnDef<AgentPortRuleDto>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Action" />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.action === 'open' ? (
|
||||
<Badge variant="success" size="sm">
|
||||
open
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" size="sm">
|
||||
close
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'protocol',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Proto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs uppercase">
|
||||
{row.original.protocol}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ports',
|
||||
accessorFn: formatPorts,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Ports" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{formatPorts(row.original)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'src',
|
||||
accessorFn: formatSrc,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Src" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{formatSrc(row.original)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="On" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
checked={row.original.enabled}
|
||||
onCheckedChange={() => toggle.mutate(row.original)}
|
||||
aria-label="toggle enabled"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={() => openEdit(row.original)}
|
||||
aria-label="Edit"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={() => remove.mutate(row.original.id)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[toggle, remove],
|
||||
)
|
||||
|
||||
const data = q.data?.items ?? []
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (r) => r.id,
|
||||
})
|
||||
|
||||
const lists = listsQ.data?.items ?? []
|
||||
const sets = setsQ.data?.items ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-px">
|
||||
<FrameTitle>Port ACL</FrameTitle>
|
||||
<FrameDescription>
|
||||
Open/close портов для all / CIDR / IP-list. Apply через nft
|
||||
(upgrade install-ссылкой).
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
Импорт
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={openCreate}>
|
||||
<PlusIcon className="size-4" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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" />
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={NetworkIcon}
|
||||
title="Нет Port ACL"
|
||||
description="Добавьте open/close или импортируйте источники из списка/набора."
|
||||
centered={false}
|
||||
className="py-8"
|
||||
action={
|
||||
<Button type="button" size="sm" onClick={openCreate}>
|
||||
Добавить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Sheet open={formOpen} onOpenChange={setFormOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>
|
||||
{editing ? 'Редактировать Port ACL' : 'Новое Port ACL'}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Preview: https://reui.io/preview/base/sheet-8
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="flex flex-col gap-3 py-2 pb-4">
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<Select
|
||||
value={form.action}
|
||||
onValueChange={(v) =>
|
||||
v && setForm((f) => ({ ...f, action: v as 'open' | 'close' }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">open</SelectItem>
|
||||
<SelectItem value="close">close</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Protocol</FieldLabel>
|
||||
<Select
|
||||
value={form.protocol}
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
protocol: v as FormState['protocol'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">tcp</SelectItem>
|
||||
<SelectItem value="udp">udp</SelectItem>
|
||||
<SelectItem value="both">both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field>
|
||||
<FieldLabel>Port start</FieldLabel>
|
||||
<Input
|
||||
value={form.port_start}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, port_start: e.target.value }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
placeholder="443"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Port end</FieldLabel>
|
||||
<Input
|
||||
value={form.port_end}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, port_end: e.target.value }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
placeholder="optional"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>Source</FieldLabel>
|
||||
<Select
|
||||
value={form.src_kind}
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
src_kind: v as FormState['src_kind'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">all (0.0.0.0/0)</SelectItem>
|
||||
<SelectItem value="cidr">CIDR / IP</SelectItem>
|
||||
<SelectItem value="list">IP list</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{form.src_kind === 'cidr' ? (
|
||||
<Field>
|
||||
<FieldLabel>CIDR</FieldLabel>
|
||||
<Input
|
||||
value={form.src_cidr}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, src_cidr: e.target.value }))
|
||||
}
|
||||
placeholder="10.0.0.0/8"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{form.src_kind === 'list' ? (
|
||||
<Field>
|
||||
<FieldLabel>List</FieldLabel>
|
||||
<Select
|
||||
value={form.list_id}
|
||||
onValueChange={(v) =>
|
||||
v && setForm((f) => ({ ...f, list_id: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Выберите список" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lists.map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel>Comment</FieldLabel>
|
||||
<Input
|
||||
value={form.comment}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, comment: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="shrink-0 flex-row gap-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setFormOpen(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={save.isPending || !form.port_start}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Sheet open={importOpen} onOpenChange={setImportOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Импорт Port ACL</SheetTitle>
|
||||
<SheetDescription>
|
||||
Источник CIDR/list из набора или IP-list + порты.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="flex flex-col gap-3 py-2 pb-4">
|
||||
<Field>
|
||||
<FieldLabel>From</FieldLabel>
|
||||
<Select
|
||||
value={impFrom}
|
||||
onValueChange={(v) =>
|
||||
v && setImpFrom(v as 'list' | 'set')
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="list">IP list</SelectItem>
|
||||
<SelectItem value="set">Policy set</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{impFrom === 'list' ? (
|
||||
<Field>
|
||||
<FieldLabel>List</FieldLabel>
|
||||
<Select
|
||||
value={impListId}
|
||||
onValueChange={(v) => v && setImpListId(v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Список" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lists.map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel>Set</FieldLabel>
|
||||
<Select
|
||||
value={impSetId}
|
||||
onValueChange={(v) => v && setImpSetId(v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Набор" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sets.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<Select
|
||||
value={impAction}
|
||||
onValueChange={(v) =>
|
||||
v && setImpAction(v as 'open' | 'close')
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">open</SelectItem>
|
||||
<SelectItem value="close">close</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Protocol</FieldLabel>
|
||||
<Select
|
||||
value={impProtocol}
|
||||
onValueChange={(v) =>
|
||||
v && setImpProtocol(v as typeof impProtocol)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">tcp</SelectItem>
|
||||
<SelectItem value="udp">udp</SelectItem>
|
||||
<SelectItem value="both">both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Ports</FieldLabel>
|
||||
<Input
|
||||
value={impPorts}
|
||||
onChange={(e) => setImpPorts(e.target.value)}
|
||||
placeholder="22,80,443 или 8000-8010"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="shrink-0 flex-row gap-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setImportOpen(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={doImport.isPending}
|
||||
onClick={() => doImport.mutate()}
|
||||
>
|
||||
Импортировать
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user