feat(api, web): enhance port ACL handling and documentation
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m20s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Updated the `evofw-firewall.sh` script to refine the port ACL logic, ensuring the correct order of operations for deny and allow rules.
- Introduced a new structure for port ACL rows in the UI, allowing for better management of system and EvoFW rules.
- Enhanced the documentation to clarify the new port ACL behavior, including implicit drops for open ports and the distinction between EvoFW and system rules.
- Improved the handling of port ranges and source addresses in the UI, ensuring accurate representation of firewall rules.

These changes improve the functionality and clarity of port ACL management, enhancing user experience and system reliability.
This commit is contained in:
Denozordec
2026-08-16 16:28:11 +07:00
parent d552f4f326
commit a2ad637a38
4 changed files with 491 additions and 92 deletions
+442 -70
View File
@@ -20,10 +20,13 @@ import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { Badge } from '@/components/reui/badge'
import { EmptyState } from '@/components/empty-state'
import {
agentHostFirewallQueryOptions,
agentPortRulesQueryOptions,
listsQueryOptions,
policySetsQueryOptions,
type AgentPortRuleDto,
type HostFwRuleDto,
type HostListenerDto,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
@@ -49,9 +52,13 @@ import {
import { ScrollArea } from '@evofw/ui/components/scroll-area'
/**
* Desired Port ACL for Linux agent.
* Desired Port ACL for Linux agent + system overlay from host snapshot.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* · https://reui.io/preview/base/sheet-8
* · https://reui.io/preview/base/empty-state-12
* Docs: https://reui.io/docs/components/base/select
* · https://reui.io/docs/components/base/badge
* · https://reui.io/docs/components/base/sheet
*/
type AgentPortAclProps = {
@@ -70,6 +77,30 @@ type FormState = {
enabled: boolean
}
type PortAclOwner = 'evofw' | 'system'
type PortAclRow = {
id: string
owner: PortAclOwner
overridden: boolean
action: 'open' | 'close'
protocol: 'tcp' | 'udp' | 'both'
port_start: number
port_end: number
src_kind: 'all' | 'cidr' | 'list'
src_cidr?: string | null
list_id?: string | null
list_name?: string | null
enabled: boolean
comment?: string | null
}
const OWNER_ITEMS = [
{ value: 'all', label: 'All owners' },
{ value: 'evofw', label: 'EvoFW' },
{ value: 'system', label: 'System' },
] as const
const emptyForm = (): FormState => ({
action: 'open',
protocol: 'tcp',
@@ -82,25 +113,216 @@ const emptyForm = (): FormState => ({
enabled: true,
})
function formatPorts(r: AgentPortRuleDto): string {
function formatPorts(r: Pick<PortAclRow, 'port_start' | 'port_end'>): string {
return r.port_start === r.port_end
? String(r.port_start)
: `${r.port_start}-${r.port_end}`
}
function formatSrc(r: AgentPortRuleDto): string {
function formatSrc(r: PortAclRow): 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'
}
function normalizeProto(value?: string): 'tcp' | 'udp' | null {
const p = (value || '').toLowerCase()
if (p === 'tcp' || p.startsWith('tcp')) return 'tcp'
if (p === 'udp' || p.startsWith('udp')) return 'udp'
return null
}
function parsePortRanges(value?: string): Array<{ start: number; end: number }> {
if (!value) return []
const t = value.replace(/[{}]/g, ' ').trim()
const out: Array<{ start: number; end: number }> = []
for (const part of t.split(/[,\s]+/)) {
if (!part) continue
if (part.includes('-')) {
const [a, b] = part.split('-')
const start = Number(a)
const end = Number(b)
if (
Number.isInteger(start) &&
Number.isInteger(end) &&
start >= 1 &&
end <= 65535 &&
end >= start
) {
out.push({ start, end })
}
continue
}
const n = Number(part)
if (Number.isInteger(n) && n >= 1 && n <= 65535) {
out.push({ start: n, end: n })
}
}
return out
}
function isLoopbackAddr(addr: string): boolean {
const a = addr.replace(/^\[|\]$/g, '').toLowerCase()
return a === '127.0.0.1' || a === '::1' || a === 'localhost'
}
function isWildcardAddr(addr: string): boolean {
const a = addr.replace(/^\[|\]$/g, '')
return a === '0.0.0.0' || a === '*' || a === '::' || a === ''
}
function isAllowAction(action?: string): boolean {
const a = (action || '').toLowerCase()
return a === 'accept' || a === 'allow'
}
function parseUfwLikeRaw(
raw: string,
): Array<{ proto: 'tcp' | 'udp'; start: number; end: number }> {
const out: Array<{ proto: 'tcp' | 'udp'; start: number; end: number }> = []
const re = /(\d{1,5})(?:-(\d{1,5}))?\/(tcp|udp)/gi
let m: RegExpExecArray | null
while ((m = re.exec(raw)) !== null) {
const start = Number(m[1])
const end = m[2] ? Number(m[2]) : start
const proto = m[3].toLowerCase() as 'tcp' | 'udp'
if (start >= 1 && end <= 65535 && end >= start) {
out.push({ proto, start, end })
}
}
return out
}
function coversPort(
rules: AgentPortRuleDto[],
proto: 'tcp' | 'udp',
start: number,
end: number,
): boolean {
return rules.some((r) => {
if (!r.enabled) return false
const protos =
r.protocol === 'both' ? (['tcp', 'udp'] as const) : [r.protocol]
if (!protos.includes(proto)) return false
return r.port_start <= start && r.port_end >= end
})
}
function collectSystemRows(
listeners: HostListenerDto[],
rules: HostFwRuleDto[],
evofw: AgentPortRuleDto[],
): PortAclRow[] {
type Acc = {
proto: 'tcp' | 'udp'
start: number
end: number
src: string
hasExternal: boolean
hasLoopbackOnly: boolean
}
const map = new Map<string, Acc>()
const upsert = (
proto: 'tcp' | 'udp',
start: number,
end: number,
src: string,
bind: 'external' | 'loopback' | 'unknown',
) => {
const key = `${proto}:${start}:${end}`
const prev = map.get(key)
if (!prev) {
map.set(key, {
proto,
start,
end,
src,
hasExternal: bind !== 'loopback',
hasLoopbackOnly: bind === 'loopback',
})
return
}
if (bind === 'external') prev.hasExternal = true
if (bind !== 'loopback') prev.hasLoopbackOnly = false
if (src && src !== 'all' && prev.src === 'all') prev.src = src
}
for (const l of listeners) {
const proto = normalizeProto(l.protocol)
if (!proto) continue
const port = l.port
if (!Number.isInteger(port) || port < 1 || port > 65535) continue
const bind = isLoopbackAddr(l.address)
? 'loopback'
: isWildcardAddr(l.address) || l.address
? 'external'
: 'unknown'
upsert(proto, port, port, 'all', bind)
}
for (const r of rules) {
if (r.ownership === 'evofw') continue
if (!isAllowAction(r.action) && r.backend !== 'ufw' && r.backend !== 'firewalld') {
continue
}
if (r.backend === 'ufw' || r.backend === 'firewalld') {
if (r.action && !isAllowAction(r.action) && r.backend === 'ufw') continue
const parsed = parseUfwLikeRaw(r.raw)
if (parsed.length) {
for (const p of parsed) {
upsert(p.proto, p.start, p.end, 'all', 'external')
}
continue
}
}
const proto = normalizeProto(r.protocol)
const ranges = parsePortRanges(r.dport)
if (!proto || !ranges.length) {
const parsed = parseUfwLikeRaw(r.raw)
for (const p of parsed) {
upsert(p.proto, p.start, p.end, r.saddr || 'all', 'external')
}
continue
}
if (!isAllowAction(r.action)) continue
for (const range of ranges) {
upsert(proto, range.start, range.end, r.saddr || 'all', 'external')
}
}
const rows: PortAclRow[] = []
for (const acc of map.values()) {
if (!acc.hasExternal && acc.hasLoopbackOnly) continue
const src = acc.src && acc.src !== 'all' ? acc.src : 'all'
rows.push({
id: `system-${acc.proto}-${acc.start}-${acc.end}`,
owner: 'system',
overridden: coversPort(evofw, acc.proto, acc.start, acc.end),
action: 'open',
protocol: acc.proto,
port_start: acc.start,
port_end: acc.end,
src_kind: src === 'all' ? 'all' : 'cidr',
src_cidr: src === 'all' ? null : src,
enabled: true,
comment: null,
})
}
return rows.sort(
(a, b) => a.port_start - b.port_start || a.protocol.localeCompare(b.protocol),
)
}
export function AgentPortAcl({ agentId }: AgentPortAclProps) {
const qc = useQueryClient()
const q = useQuery(agentPortRulesQueryOptions(agentId))
const hostQ = useQuery(agentHostFirewallQueryOptions(agentId))
const listsQ = useQuery(listsQueryOptions())
const setsQ = useQuery(policySetsQueryOptions())
const [formOpen, setFormOpen] = useState(false)
const [editing, setEditing] = useState<AgentPortRuleDto | null>(null)
const [overriding, setOverriding] = useState(false)
const [form, setForm] = useState<FormState>(emptyForm)
const [importOpen, setImportOpen] = useState(false)
const [impFrom, setImpFrom] = useState<'list' | 'set'>('list')
@@ -109,9 +331,22 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
const [impAction, setImpAction] = useState<'open' | 'close'>('open')
const [impProtocol, setImpProtocol] = useState<'tcp' | 'udp' | 'both'>('tcp')
const [impPorts, setImpPorts] = useState('22,80,443')
const [ownerFilter, setOwnerFilter] = useState<'all' | PortAclOwner>('all')
const lists = listsQ.data?.items ?? []
const sets = setsQ.data?.items ?? []
const listSelectItems = useMemo(
() => lists.map((l) => ({ value: l.id, label: l.name })),
[lists],
)
const setSelectItems = useMemo(
() => sets.map((s) => ({ value: s.id, label: s.name })),
[sets],
)
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'port-rules'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'host-firewall'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'preview'] })
}
@@ -143,9 +378,16 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
})
},
onSuccess: () => {
toast.success(editing ? 'Правило обновлено' : 'Правило создано')
toast.success(
editing
? 'Правило обновлено'
: overriding
? 'Порт переопределён'
: 'Правило создано',
)
setFormOpen(false)
setEditing(null)
setOverriding(false)
setForm(emptyForm())
invalidate()
},
@@ -215,18 +457,19 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
const openCreate = () => {
setEditing(null)
setOverriding(false)
setForm(emptyForm())
setFormOpen(true)
}
const openEdit = (row: AgentPortRuleDto) => {
setEditing(row)
setOverriding(false)
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) : '',
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 || '',
@@ -236,8 +479,72 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
setFormOpen(true)
}
const columns = useMemo<ColumnDef<AgentPortRuleDto>[]>(
const openOverride = (row: PortAclRow) => {
setEditing(null)
setOverriding(true)
setForm({
...emptyForm(),
action: 'open',
protocol: row.protocol,
port_start: String(row.port_start),
port_end: row.port_end !== row.port_start ? String(row.port_end) : '',
src_kind: 'list',
})
setFormOpen(true)
}
const evofwRules = q.data?.items ?? []
const systemRows = useMemo(
() =>
collectSystemRows(
hostQ.data?.listeners ?? [],
hostQ.data?.rules ?? [],
evofwRules,
),
[hostQ.data?.listeners, hostQ.data?.rules, evofwRules],
)
const allRows = useMemo<PortAclRow[]>(() => {
const evofw: PortAclRow[] = evofwRules.map((r) => ({
...r,
owner: 'evofw',
overridden: false,
}))
return [...evofw, ...systemRows]
}, [evofwRules, systemRows])
const data = useMemo(() => {
if (ownerFilter === 'all') return allRows
return allRows.filter((r) => r.owner === ownerFilter)
}, [allRows, ownerFilter])
const columns = useMemo<ColumnDef<PortAclRow>[]>(
() => [
{
id: 'owner',
accessorKey: 'owner',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Owner" />
),
cell: ({ row }) =>
row.original.owner === 'evofw' ? (
<Badge variant="success" size="sm">
EvoFW
</Badge>
) : (
<div className="flex items-center gap-1.5">
<Badge variant="secondary" size="sm">
system
</Badge>
{row.original.overridden ? (
<span className="text-muted-foreground text-[11px]">
переопределён
</span>
) : null}
</div>
),
meta: { headerTitle: 'Owner' },
},
{
accessorKey: 'action',
header: ({ column }) => (
@@ -292,47 +599,72 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
header: ({ column }) => (
<DataGridColumnHeader column={column} title="On" />
),
cell: ({ row }) => (
<Switch
checked={row.original.enabled}
onCheckedChange={() => toggle.mutate(row.original)}
aria-label="toggle enabled"
/>
),
cell: ({ row }) =>
row.original.owner === 'evofw' ? (
<Switch
checked={row.original.enabled}
onCheckedChange={() => {
const src = evofwRules.find((r) => r.id === row.original.id)
if (src) toggle.mutate(src)
}}
aria-label="toggle enabled"
/>
) : (
<span className="text-muted-foreground text-xs"></span>
),
},
{
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>
),
cell: ({ row }) => {
if (row.original.owner === 'system') {
if (row.original.overridden) {
return <span className="text-muted-foreground text-xs"></span>
}
return (
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
onClick={() => openOverride(row.original)}
aria-label="Переопределить"
>
<PencilIcon className="size-3.5" />
</Button>
)
}
const src = evofwRules.find((r) => r.id === row.original.id)
return (
<div className="flex items-center gap-1">
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
onClick={() => src && openEdit(src)}
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],
[toggle, remove, evofwRules],
)
const data = q.data?.items ?? []
const table = useReactTable({
data,
columns,
@@ -340,8 +672,12 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
getRowId: (r) => r.id,
})
const lists = listsQ.data?.items ?? []
const sets = setsQ.data?.items ?? []
const isLoading = q.isLoading || hostQ.isLoading
const sheetTitle = editing
? 'Редактировать Port ACL'
: overriding
? 'Переопределить Port ACL'
: 'Новое Port ACL'
return (
<>
@@ -351,7 +687,8 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
<div className="flex flex-col gap-px">
<FrameTitle>Port ACL</FrameTitle>
<FrameDescription>
Open/close портов для all / CIDR / IP-list. Apply через nft
Open по списку делает порт whitelist (остальные src drop).
Системные порты с хоста можно переопределить. Apply через nft
(upgrade install-ссылкой).
</FrameDescription>
</div>
@@ -371,17 +708,35 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
</div>
</div>
</FrameHeader>
<FramePanel className="p-0">
{q.isLoading ? (
<div className="flex flex-col gap-2 p-4">
<FramePanel className="flex flex-col gap-3 p-4">
<Select
items={[...OWNER_ITEMS]}
value={ownerFilter}
onValueChange={(v) => {
if (v) setOwnerFilter(v as typeof ownerFilter)
}}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Owner" />
</SelectTrigger>
<SelectContent>
{OWNER_ITEMS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
{isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : data.length === 0 ? (
) : allRows.length === 0 ? (
<EmptyState
icon={NetworkIcon}
title="Нет Port ACL"
description="Добавьте open/close или импортируйте источники из списка/набора."
description="Добавьте open/close или импортируйте источники из списка/набора. После sync появятся системные порты с хоста."
centered={false}
className="py-8"
action={
@@ -402,14 +757,23 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
</FramePanel>
</Frame>
<Sheet open={formOpen} onOpenChange={setFormOpen}>
<Sheet
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open)
if (!open) {
setEditing(null)
setOverriding(false)
}
}}
>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>
{editing ? 'Редактировать Port ACL' : 'Новое Port ACL'}
</SheetTitle>
<SheetTitle>{sheetTitle}</SheetTitle>
<SheetDescription>
Preview: https://reui.io/preview/base/sheet-8
{overriding
? 'Open + список: порт станет whitelist, остальные внешние src — drop.'
: 'Preview: https://reui.io/preview/base/sheet-8'}
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
@@ -515,7 +879,8 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
<Field>
<FieldLabel>List</FieldLabel>
<Select
value={form.list_id}
items={listSelectItems}
value={form.list_id || null}
onValueChange={(v) =>
v && setForm((f) => ({ ...f, list_id: v }))
}
@@ -524,9 +889,9 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
<SelectValue placeholder="Выберите список" />
</SelectTrigger>
<SelectContent>
{lists.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
{listSelectItems.map((l) => (
<SelectItem key={l.value} value={l.value}>
{l.label}
</SelectItem>
))}
</SelectContent>
@@ -554,7 +919,11 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
</Button>
<Button
type="button"
disabled={save.isPending || !form.port_start}
disabled={
save.isPending ||
!form.port_start ||
(form.src_kind === 'list' && !form.list_id)
}
onClick={() => save.mutate()}
>
Сохранить
@@ -577,9 +946,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
<FieldLabel>From</FieldLabel>
<Select
value={impFrom}
onValueChange={(v) =>
v && setImpFrom(v as 'list' | 'set')
}
onValueChange={(v) => v && setImpFrom(v as 'list' | 'set')}
>
<SelectTrigger className="w-full">
<SelectValue />
@@ -594,16 +961,17 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
<Field>
<FieldLabel>List</FieldLabel>
<Select
value={impListId}
items={listSelectItems}
value={impListId || null}
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}
{listSelectItems.map((l) => (
<SelectItem key={l.value} value={l.value}>
{l.label}
</SelectItem>
))}
</SelectContent>
@@ -613,16 +981,17 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
<Field>
<FieldLabel>Set</FieldLabel>
<Select
value={impSetId}
items={setSelectItems}
value={impSetId || null}
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}
{setSelectItems.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
@@ -684,7 +1053,10 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
</Button>
<Button
type="button"
disabled={doImport.isPending}
disabled={
doImport.isPending ||
(impFrom === 'list' ? !impListId : !impSetId)
}
onClick={() => doImport.mutate()}
>
Импортировать