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:
@@ -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()}
|
||||
>
|
||||
Импортировать
|
||||
|
||||
Reference in New Issue
Block a user