From a2ad637a388d2485eaf57783a93f06d332e4884c Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 16 Aug 2026 16:28:11 +0700 Subject: [PATCH] feat(api, web): enhance port ACL handling and documentation - 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. --- apps/api/src/agent-scripts/evofw-firewall.sh | 63 ++- .../src/components/agents/agent-port-acl.tsx | 512 +++++++++++++++--- docs/agents.md | 6 +- docs/architecture.md | 2 +- 4 files changed, 491 insertions(+), 92 deletions(-) diff --git a/apps/api/src/agent-scripts/evofw-firewall.sh b/apps/api/src/agent-scripts/evofw-firewall.sh index 86309a4..c979e86 100644 --- a/apps/api/src/agent-scripts/evofw-firewall.sh +++ b/apps/api/src/agent-scripts/evofw-firewall.sh @@ -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 diff --git a/apps/web/src/components/agents/agent-port-acl.tsx b/apps/web/src/components/agents/agent-port-acl.tsx index 18baca5..3d8a65c 100644 --- a/apps/web/src/components/agents/agent-port-acl.tsx +++ b/apps/web/src/components/agents/agent-port-acl.tsx @@ -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): 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() + + 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(null) + const [overriding, setOverriding] = useState(false) const [form, setForm] = useState(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[]>( + 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(() => { + 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[]>( () => [ + { + id: 'owner', + accessorKey: 'owner', + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.owner === 'evofw' ? ( + + EvoFW + + ) : ( +
+ + system + + {row.original.overridden ? ( + + переопределён + + ) : null} +
+ ), + meta: { headerTitle: 'Owner' }, + }, { accessorKey: 'action', header: ({ column }) => ( @@ -292,47 +599,72 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) { header: ({ column }) => ( ), - cell: ({ row }) => ( - toggle.mutate(row.original)} - aria-label="toggle enabled" - /> - ), + cell: ({ row }) => + row.original.owner === 'evofw' ? ( + { + const src = evofwRules.find((r) => r.id === row.original.id) + if (src) toggle.mutate(src) + }} + aria-label="toggle enabled" + /> + ) : ( + + ), }, { id: 'actions', header: () => Actions, - cell: ({ row }) => ( -
- - -
- ), + cell: ({ row }) => { + if (row.original.owner === 'system') { + if (row.original.overridden) { + return + } + return ( + + ) + } + const src = evofwRules.find((r) => r.id === row.original.id) + return ( +
+ + +
+ ) + }, }, ], - [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) {
Port ACL - Open/close портов для all / CIDR / IP-list. Apply через nft + Open по списку делает порт whitelist (остальные src — drop). + Системные порты с хоста можно переопределить. Apply через nft (upgrade install-ссылкой).
@@ -371,17 +708,35 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) { - - {q.isLoading ? ( -
+ + + {isLoading ? ( +
- ) : data.length === 0 ? ( + ) : allRows.length === 0 ? ( - + { + setFormOpen(open) + if (!open) { + setEditing(null) + setOverriding(false) + } + }} + > - - {editing ? 'Редактировать Port ACL' : 'Новое Port ACL'} - + {sheetTitle} - Preview: https://reui.io/preview/base/sheet-8 + {overriding + ? 'Open + список: порт станет whitelist, остальные внешние src — drop.' + : 'Preview: https://reui.io/preview/base/sheet-8'} @@ -515,7 +879,8 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) { List - v && setImpFrom(v as 'list' | 'set') - } + onValueChange={(v) => v && setImpFrom(v as 'list' | 'set')} > @@ -594,16 +961,17 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) { List v && setImpSetId(v)} > - {sets.map((s) => ( - - {s.name} + {setSelectItems.map((s) => ( + + {s.label} ))} @@ -684,7 +1053,10 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {