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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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+)
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user