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) {