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 done
((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}" ((${#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 if [[ "$DEFAULT_ACTION" == "drop" ]]; then
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }' nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }'
else else
@@ -461,9 +461,9 @@ apply_nft() {
else else
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
fi fi
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept # Port ACL before L3 allow so open+list is exclusive (implicit drop per open port).
# Port ACL: close (drop) then open (accept), before default.
apply_nft_port_acl "$table" "$name" apply_nft_port_acl "$table" "$name"
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
if [[ "$DEFAULT_ACTION" == "drop" ]]; then if [[ "$DEFAULT_ACTION" == "drop" ]]; then
nft add rule "$table" "$name" input counter drop nft add rule "$table" "$name" input counter drop
else else
@@ -499,41 +499,68 @@ try:
except Exception: except Exception:
rules = [] rules = []
safe_id = re.compile(r"[^a-zA-Z0-9_]") 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] rid = safe_id.sub("_", str(r.get("id") or "x"))[:40]
action = r.get("action") or "open" action = r.get("action") or "open"
proto = r.get("protocol") or "tcp" proto = r.get("protocol") or "tcp"
if proto not in ("tcp", "udp"): if proto not in ("tcp", "udp"):
continue return None
ps = int(r.get("port_start") or 0) ps = int(r.get("port_start") or 0)
pe = int(r.get("port_end") or ps) pe = int(r.get("port_end") or ps)
if ps < 1 or pe > 65535 or pe < 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] cidrs = [c for c in (r.get("src_cidrs") or []) if c and ":" not in c]
if not cidrs: if not cidrs:
continue return None
verdict = "drop" if action == "close" else "accept"
dport = f"{ps}" if ps == pe else f"{ps}-{pe}" dport = f"{ps}" if ps == pe else f"{ps}-{pe}"
comment = f"evofw-port-{rid}" return {
is_all = any(c in ("0.0.0.0/0", "0.0.0.0") for c in cidrs) "rid": rid,
if is_all: "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}"') print(f'nft add rule inet evofw input {proto} dport {dport} counter {verdict} comment "{comment}"')
continue return
setname = f"port_src_{rid}" setname = f"port_src_{p['rid']}_{p['proto']}"
print(f"nft add set inet evofw {setname} '{{ type ipv4_addr; flags interval; }}'") print(f"nft add set inet evofw {setname} '{{ type ipv4_addr; flags interval; }}'")
chunk = [] chunk = []
for c in cidrs: for c in p["cidrs"]:
chunk.append(c) chunk.append(c)
if len(chunk) >= 32: if len(chunk) >= 32:
joined = ", ".join(chunk) print(f"nft add element inet evofw {setname} '{{ {', '.join(chunk)} }}'")
print(f"nft add element inet evofw {setname} '{{ {joined} }}'")
chunk = [] chunk = []
if chunk: if chunk:
joined = ", ".join(chunk) print(f"nft add element inet evofw {setname} '{{ {', '.join(chunk)} }}'")
print(f"nft add element inet evofw {setname} '{{ {joined} }}'")
print( print(
f'nft add rule inet evofw input ip saddr @{setname} {proto} dport {dport} counter {verdict} comment "{comment}"' 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 PY
local cmd local cmd
while IFS= read -r cmd; do 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 { Badge } from '@/components/reui/badge'
import { EmptyState } from '@/components/empty-state' import { EmptyState } from '@/components/empty-state'
import { import {
agentHostFirewallQueryOptions,
agentPortRulesQueryOptions, agentPortRulesQueryOptions,
listsQueryOptions, listsQueryOptions,
policySetsQueryOptions, policySetsQueryOptions,
type AgentPortRuleDto, type AgentPortRuleDto,
type HostFwRuleDto,
type HostListenerDto,
} from '@/queries' } from '@/queries'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
@@ -49,9 +52,13 @@ import {
import { ScrollArea } from '@evofw/ui/components/scroll-area' 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 * Preview: https://reui.io/preview/base/data-grid-filtering-2
* · https://reui.io/preview/base/sheet-8 * · 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 = { type AgentPortAclProps = {
@@ -70,6 +77,30 @@ type FormState = {
enabled: boolean 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 => ({ const emptyForm = (): FormState => ({
action: 'open', action: 'open',
protocol: 'tcp', protocol: 'tcp',
@@ -82,25 +113,216 @@ const emptyForm = (): FormState => ({
enabled: true, enabled: true,
}) })
function formatPorts(r: AgentPortRuleDto): string { function formatPorts(r: Pick<PortAclRow, 'port_start' | 'port_end'>): string {
return r.port_start === r.port_end return r.port_start === r.port_end
? String(r.port_start) ? String(r.port_start)
: `${r.port_start}-${r.port_end}` : `${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 === 'all') return 'all'
if (r.src_kind === 'cidr') return r.src_cidr || '—' if (r.src_kind === 'cidr') return r.src_cidr || '—'
return r.list_name || r.list_id || 'list' 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) { export function AgentPortAcl({ agentId }: AgentPortAclProps) {
const qc = useQueryClient() const qc = useQueryClient()
const q = useQuery(agentPortRulesQueryOptions(agentId)) const q = useQuery(agentPortRulesQueryOptions(agentId))
const hostQ = useQuery(agentHostFirewallQueryOptions(agentId))
const listsQ = useQuery(listsQueryOptions()) const listsQ = useQuery(listsQueryOptions())
const setsQ = useQuery(policySetsQueryOptions()) const setsQ = useQuery(policySetsQueryOptions())
const [formOpen, setFormOpen] = useState(false) const [formOpen, setFormOpen] = useState(false)
const [editing, setEditing] = useState<AgentPortRuleDto | null>(null) const [editing, setEditing] = useState<AgentPortRuleDto | null>(null)
const [overriding, setOverriding] = useState(false)
const [form, setForm] = useState<FormState>(emptyForm) const [form, setForm] = useState<FormState>(emptyForm)
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
const [impFrom, setImpFrom] = useState<'list' | 'set'>('list') const [impFrom, setImpFrom] = useState<'list' | 'set'>('list')
@@ -109,9 +331,22 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
const [impAction, setImpAction] = useState<'open' | 'close'>('open') const [impAction, setImpAction] = useState<'open' | 'close'>('open')
const [impProtocol, setImpProtocol] = useState<'tcp' | 'udp' | 'both'>('tcp') const [impProtocol, setImpProtocol] = useState<'tcp' | 'udp' | 'both'>('tcp')
const [impPorts, setImpPorts] = useState('22,80,443') 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 = () => { const invalidate = () => {
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'port-rules'] }) 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] })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'preview'] }) void qc.invalidateQueries({ queryKey: ['agents', agentId, 'preview'] })
} }
@@ -143,9 +378,16 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
}) })
}, },
onSuccess: () => { onSuccess: () => {
toast.success(editing ? 'Правило обновлено' : 'Правило создано') toast.success(
editing
? 'Правило обновлено'
: overriding
? 'Порт переопределён'
: 'Правило создано',
)
setFormOpen(false) setFormOpen(false)
setEditing(null) setEditing(null)
setOverriding(false)
setForm(emptyForm()) setForm(emptyForm())
invalidate() invalidate()
}, },
@@ -215,18 +457,19 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
const openCreate = () => { const openCreate = () => {
setEditing(null) setEditing(null)
setOverriding(false)
setForm(emptyForm()) setForm(emptyForm())
setFormOpen(true) setFormOpen(true)
} }
const openEdit = (row: AgentPortRuleDto) => { const openEdit = (row: AgentPortRuleDto) => {
setEditing(row) setEditing(row)
setOverriding(false)
setForm({ setForm({
action: row.action, action: row.action,
protocol: row.protocol, protocol: row.protocol,
port_start: String(row.port_start), port_start: String(row.port_start),
port_end: port_end: row.port_end !== row.port_start ? String(row.port_end) : '',
row.port_end !== row.port_start ? String(row.port_end) : '',
src_kind: row.src_kind, src_kind: row.src_kind,
src_cidr: row.src_cidr || '', src_cidr: row.src_cidr || '',
list_id: row.list_id || '', list_id: row.list_id || '',
@@ -236,8 +479,72 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
setFormOpen(true) 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', accessorKey: 'action',
header: ({ column }) => ( header: ({ column }) => (
@@ -292,47 +599,72 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="On" /> <DataGridColumnHeader column={column} title="On" />
), ),
cell: ({ row }) => ( cell: ({ row }) =>
<Switch row.original.owner === 'evofw' ? (
checked={row.original.enabled} <Switch
onCheckedChange={() => toggle.mutate(row.original)} checked={row.original.enabled}
aria-label="toggle 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', id: 'actions',
header: () => <span className="sr-only">Actions</span>, header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => ( cell: ({ row }) => {
<div className="flex items-center gap-1"> if (row.original.owner === 'system') {
<Button if (row.original.overridden) {
type="button" return <span className="text-muted-foreground text-xs"></span>
size="icon" }
variant="ghost" return (
className="size-7" <Button
onClick={() => openEdit(row.original)} type="button"
aria-label="Edit" size="icon"
> variant="ghost"
<PencilIcon className="size-3.5" /> className="size-7"
</Button> onClick={() => openOverride(row.original)}
<Button aria-label="Переопределить"
type="button" >
size="icon" <PencilIcon className="size-3.5" />
variant="ghost" </Button>
className="size-7" )
onClick={() => remove.mutate(row.original.id)} }
aria-label="Delete" const src = evofwRules.find((r) => r.id === row.original.id)
> return (
<Trash2Icon className="size-3.5" /> <div className="flex items-center gap-1">
</Button> <Button
</div> 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({ const table = useReactTable({
data, data,
columns, columns,
@@ -340,8 +672,12 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
getRowId: (r) => r.id, getRowId: (r) => r.id,
}) })
const lists = listsQ.data?.items ?? [] const isLoading = q.isLoading || hostQ.isLoading
const sets = setsQ.data?.items ?? [] const sheetTitle = editing
? 'Редактировать Port ACL'
: overriding
? 'Переопределить Port ACL'
: 'Новое Port ACL'
return ( return (
<> <>
@@ -351,7 +687,8 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
<div className="flex flex-col gap-px"> <div className="flex flex-col gap-px">
<FrameTitle>Port ACL</FrameTitle> <FrameTitle>Port ACL</FrameTitle>
<FrameDescription> <FrameDescription>
Open/close портов для all / CIDR / IP-list. Apply через nft Open по списку делает порт whitelist (остальные src drop).
Системные порты с хоста можно переопределить. Apply через nft
(upgrade install-ссылкой). (upgrade install-ссылкой).
</FrameDescription> </FrameDescription>
</div> </div>
@@ -371,17 +708,35 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
</div> </div>
</div> </div>
</FrameHeader> </FrameHeader>
<FramePanel className="p-0"> <FramePanel className="flex flex-col gap-3 p-4">
{q.isLoading ? ( <Select
<div className="flex flex-col gap-2 p-4"> 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" />
<Skeleton className="h-8 w-full" /> <Skeleton className="h-8 w-full" />
</div> </div>
) : data.length === 0 ? ( ) : allRows.length === 0 ? (
<EmptyState <EmptyState
icon={NetworkIcon} icon={NetworkIcon}
title="Нет Port ACL" title="Нет Port ACL"
description="Добавьте open/close или импортируйте источники из списка/набора." description="Добавьте open/close или импортируйте источники из списка/набора. После sync появятся системные порты с хоста."
centered={false} centered={false}
className="py-8" className="py-8"
action={ action={
@@ -402,14 +757,23 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
</FramePanel> </FramePanel>
</Frame> </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"> <SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0"> <SheetHeader className="shrink-0">
<SheetTitle> <SheetTitle>{sheetTitle}</SheetTitle>
{editing ? 'Редактировать Port ACL' : 'Новое Port ACL'}
</SheetTitle>
<SheetDescription> <SheetDescription>
Preview: https://reui.io/preview/base/sheet-8 {overriding
? 'Open + список: порт станет whitelist, остальные внешние src — drop.'
: 'Preview: https://reui.io/preview/base/sheet-8'}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<ScrollArea className="flex-1 px-4"> <ScrollArea className="flex-1 px-4">
@@ -515,7 +879,8 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
<Field> <Field>
<FieldLabel>List</FieldLabel> <FieldLabel>List</FieldLabel>
<Select <Select
value={form.list_id} items={listSelectItems}
value={form.list_id || null}
onValueChange={(v) => onValueChange={(v) =>
v && setForm((f) => ({ ...f, list_id: v })) v && setForm((f) => ({ ...f, list_id: v }))
} }
@@ -524,9 +889,9 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
<SelectValue placeholder="Выберите список" /> <SelectValue placeholder="Выберите список" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{lists.map((l) => ( {listSelectItems.map((l) => (
<SelectItem key={l.id} value={l.id}> <SelectItem key={l.value} value={l.value}>
{l.name} {l.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -554,7 +919,11 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
</Button> </Button>
<Button <Button
type="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()} onClick={() => save.mutate()}
> >
Сохранить Сохранить
@@ -577,9 +946,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
<FieldLabel>From</FieldLabel> <FieldLabel>From</FieldLabel>
<Select <Select
value={impFrom} value={impFrom}
onValueChange={(v) => onValueChange={(v) => v && setImpFrom(v as 'list' | 'set')}
v && setImpFrom(v as 'list' | 'set')
}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue />
@@ -594,16 +961,17 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
<Field> <Field>
<FieldLabel>List</FieldLabel> <FieldLabel>List</FieldLabel>
<Select <Select
value={impListId} items={listSelectItems}
value={impListId || null}
onValueChange={(v) => v && setImpListId(v)} onValueChange={(v) => v && setImpListId(v)}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Список" /> <SelectValue placeholder="Список" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{lists.map((l) => ( {listSelectItems.map((l) => (
<SelectItem key={l.id} value={l.id}> <SelectItem key={l.value} value={l.value}>
{l.name} {l.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -613,16 +981,17 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
<Field> <Field>
<FieldLabel>Set</FieldLabel> <FieldLabel>Set</FieldLabel>
<Select <Select
value={impSetId} items={setSelectItems}
value={impSetId || null}
onValueChange={(v) => v && setImpSetId(v)} onValueChange={(v) => v && setImpSetId(v)}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Набор" /> <SelectValue placeholder="Набор" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{sets.map((s) => ( {setSelectItems.map((s) => (
<SelectItem key={s.id} value={s.id}> <SelectItem key={s.value} value={s.value}>
{s.name} {s.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -684,7 +1053,10 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
</Button> </Button>
<Button <Button
type="button" type="button"
disabled={doImport.isPending} disabled={
doImport.isPending ||
(impFrom === 'list' ? !impListId : !impSetId)
}
onClick={() => doImport.mutate()} 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) - 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` - Policy `apply_version: 3``port_rules[]` с expanded `src_cidrs`
- nft apply: после L3 allow close drop, затем open accept (`comment "evofw-port-<id>"`) - 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) - 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 - 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+) ## 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`) - Правило в наборе: `action: deny | allow` + ровно один источник — IP-список (`list_id`), CIDR или DNS-имя (`hostname` → A/AAAA, кэш в `policy_rule_resolved`)
- Evaluate: правила всех назначенных enabled-наборов (sort + priority) + `ip_overrides` - Evaluate: правила всех назначенных enabled-наборов (sort + priority) + `ip_overrides`
- Цепочка ядра **всегда**: deny → allow → `default_action` (`accept` | `drop` на агенте) - Цепочка ядра **всегда**: 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 - Exact overlap: `allow \ deny` (`conflicts_dropped`); deny wins
- Overrides, смена наборов, `default_action`, Port ACL и refresh DNS/lists бампят `policy_generation` - Overrides, смена наборов, `default_action`, Port ACL и refresh DNS/lists бампят `policy_generation`