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
+45 -18
View File
@@ -438,7 +438,7 @@ apply_nft() {
done
((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"
# Unified chain: deny → allow → default_action
# Unified chain: deny → Port ACL (close/open/implicit) → allow → default_action
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }'
else
@@ -461,9 +461,9 @@ apply_nft() {
else
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
fi
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
# Port ACL: close (drop) then open (accept), before default.
# Port ACL before L3 allow so open+list is exclusive (implicit drop per open port).
apply_nft_port_acl "$table" "$name"
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
nft add rule "$table" "$name" input counter drop
else
@@ -499,41 +499,68 @@ try:
except Exception:
rules = []
safe_id = re.compile(r"[^a-zA-Z0-9_]")
for r in rules:
def parse_rule(r):
rid = safe_id.sub("_", str(r.get("id") or "x"))[:40]
action = r.get("action") or "open"
proto = r.get("protocol") or "tcp"
if proto not in ("tcp", "udp"):
continue
return None
ps = int(r.get("port_start") or 0)
pe = int(r.get("port_end") or ps)
if ps < 1 or pe > 65535 or pe < ps:
continue
return None
cidrs = [c for c in (r.get("src_cidrs") or []) if c and ":" not in c]
if not cidrs:
continue
verdict = "drop" if action == "close" else "accept"
return None
dport = f"{ps}" if ps == pe else f"{ps}-{pe}"
comment = f"evofw-port-{rid}"
is_all = any(c in ("0.0.0.0/0", "0.0.0.0") for c in cidrs)
if is_all:
return {
"rid": rid,
"action": action,
"proto": proto,
"dport": dport,
"cidrs": cidrs,
"is_all": any(c in ("0.0.0.0/0", "0.0.0.0") for c in cidrs),
}
def emit(p):
verdict = "drop" if p["action"] == "close" else "accept"
comment = f"evofw-port-{p['rid']}"
proto, dport = p["proto"], p["dport"]
if p["is_all"]:
print(f'nft add rule inet evofw input {proto} dport {dport} counter {verdict} comment "{comment}"')
continue
setname = f"port_src_{rid}"
return
setname = f"port_src_{p['rid']}_{p['proto']}"
print(f"nft add set inet evofw {setname} '{{ type ipv4_addr; flags interval; }}'")
chunk = []
for c in cidrs:
for c in p["cidrs"]:
chunk.append(c)
if len(chunk) >= 32:
joined = ", ".join(chunk)
print(f"nft add element inet evofw {setname} '{{ {joined} }}'")
print(f"nft add element inet evofw {setname} '{{ {', '.join(chunk)} }}'")
chunk = []
if chunk:
joined = ", ".join(chunk)
print(f"nft add element inet evofw {setname} '{{ {joined} }}'")
print(f"nft add element inet evofw {setname} '{{ {', '.join(chunk)} }}'")
print(
f'nft add rule inet evofw input ip saddr @{setname} {proto} dport {dport} counter {verdict} comment "{comment}"'
)
parsed = [p for p in (parse_rule(r) for r in rules) if p]
closes = [p for p in parsed if p["action"] == "close"]
opens = [p for p in parsed if p["action"] != "close"]
for p in closes:
emit(p)
for p in opens:
emit(p)
seen = set()
for p in opens:
key = (p["proto"], p["dport"])
if key in seen:
continue
seen.add(key)
comment = f"evofw-port-implicit-{p['proto']}-{p['dport']}"
print(
f'nft add rule inet evofw input {p["proto"]} dport {p["dport"]} counter drop comment "{comment}"'
)
PY
local cmd
while IFS= read -r cmd; do
+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()}
>
Импортировать
+3 -3
View File
@@ -115,11 +115,11 @@ Per-agent таблица `agent_port_rules`: `open|close`, `tcp|udp|both`, port
- API: CRUD `/api/v1/agents/:id/port-rules`, import `/port-rules/import` (from list или policy set sources)
- Policy `apply_version: 3``port_rules[]` с expanded `src_cidrs`
- nft apply: после L3 allow close drop, затем open accept (`comment "evofw-port-<id>"`)
- UI: tab **Port ACL** (DataGrid + Sheet create/edit + Import)
- nft apply (после deny, **до** L3 allow): close drop open accept (`comment "evofw-port-<id>"`)**implicit drop** для каждого `(proto, dport)` с хотя бы одним `open` (`comment "evofw-port-implicit-…"`). Порт с open становится whitelist: src из правила — accept, остальные внешние — drop. `lo` и `ct established,related` по-прежнему выше по цепочке.
- UI: tab **Port ACL** (DataGrid + Sheet create/edit + Import). Owner: **EvoFW** (desired) и **system** (listeners + foreign allow с хоста). Системный порт можно переопределить → создаётся desired `open` (список/CIDR); ufw/iptables не меняются.
- ipset / MikroTik: без L4 apply; секции скрыты для non-linux
Мутация Port ACL бампит `policy_generation` → agent re-apply.
Мутация Port ACL бампит `policy_generation` → agent re-apply. Нужен self-update скрипта или re-run install-ссылки.
## MikroTik (RouterOS 7.21+)
+1 -1
View File
@@ -28,7 +28,7 @@
- Правило в наборе: `action: deny | allow` + ровно один источник — IP-список (`list_id`), CIDR или DNS-имя (`hostname` → A/AAAA, кэш в `policy_rule_resolved`)
- Evaluate: правила всех назначенных enabled-наборов (sort + priority) + `ip_overrides`
- Цепочка ядра **всегда**: deny → allow → `default_action` (`accept` | `drop` на агенте)
- На Linux nft: после allow — **Port ACL** (`close` drop, затем `open` accept) из `agent_port_rules`
- На Linux nft: deny → **Port ACL** (`close` drop, `open` accept, затем implicit drop для портов с open) → allow → `default_action`
- Exact overlap: `allow \ deny` (`conflicts_dropped`); deny wins
- Overrides, смена наборов, `default_action`, Port ACL и refresh DNS/lists бампят `policy_generation`