diff --git a/apps/api/src/routes/control.ts b/apps/api/src/routes/control.ts index 155df86..3fd26cd 100644 --- a/apps/api/src/routes/control.ts +++ b/apps/api/src/routes/control.ts @@ -7,6 +7,8 @@ import { createPolicySetBodySchema, createInstallLinkBodySchema, patchPolicySetBodySchema, + patchPolicyRuleBodySchema, + reorderPolicyRulesBodySchema, putAgentPolicySetsBodySchema, patchAgentBodySchema, cloneFromBodySchema, @@ -74,6 +76,8 @@ function mapPolicySet( name: s.name, description: s.description, enabled: s.enabled === 1, + policy_mode: + s.policyMode === 'whitelist' ? ('whitelist' as const) : ('blacklist' as const), rules_count: repos.countRulesInSet(db, s.id), agents_count: repos.countAgentsForSet(db, s.id), created_at: s.createdAt, @@ -90,6 +94,7 @@ function mapPolicyRule( set_id: r.setId, priority: r.priority, action: r.action, + enabled: r.enabled !== 0, list_id: r.listId, cidr: r.cidr, hostname: r.hostname, @@ -496,6 +501,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( name: body.name.trim(), description: body.description ?? null, enabled: body.enabled === false ? 0 : 1, + policyMode: body.policy_mode === 'whitelist' ? 'whitelist' : 'blacklist', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) @@ -510,8 +516,37 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( name: body.name?.trim(), description: body.description, enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0, + policyMode: body.policy_mode, }) - if (body.enabled !== undefined) repos.bumpAgentsForSet(app.db, s.id) + if (body.enabled !== undefined || body.policy_mode !== undefined) { + repos.bumpAgentsForSet(app.db, s.id) + } + // Sync agent.policy_mode cache when set mode changes + if (body.policy_mode) { + for (const agentId of repos.listAgentIdsForSet(app.db, s.id)) { + try { + const sets = repos.listSetsForAgent(app.db, agentId) + const modes = new Set( + sets + .filter((x) => x.enabled === 1) + .map((x) => + x.policyMode === 'whitelist' ? 'whitelist' : 'blacklist', + ), + ) + if (modes.size > 1) { + throw new AppError( + 'VALIDATION_ERROR', + 'агент имеет наборы с разными режимами — выровняйте mode', + 400, + ) + } + const mode = [...modes][0] ?? body.policy_mode + repos.updateAgent(app.db, agentId, { policyMode: mode }) + } catch (err) { + if (err instanceof AppError) throw err + } + } + } return mapPolicySet(updated!, app.db) }) @@ -554,7 +589,15 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( throw new AppError('NOT_FOUND', `Policy set not found: ${setId}`, 404) } } - repos.setAgentPolicySets(app.db, a.id, body.set_ids) + try { + repos.setAgentPolicySets(app.db, a.id, body.set_ids) + } catch (err) { + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } return { items: repos.listSetsForAgent(app.db, a.id).map((s) => ({ set_id: s.setId, @@ -562,6 +605,8 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( name: s.name, description: s.description, enabled: s.enabled === 1, + policy_mode: + s.policyMode === 'whitelist' ? 'whitelist' : 'blacklist', })), } }, @@ -579,6 +624,8 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( name: s.name, description: s.description, enabled: s.enabled === 1, + policy_mode: + s.policyMode === 'whitelist' ? 'whitelist' : 'blacklist', })), } }, @@ -627,12 +674,16 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( throw new AppError('NOT_FOUND', 'IP list not found', 404) } + const priority = + body.priority ?? repos.nextRulePriority(app.db, body.set_id) + const id = crypto.randomUUID() const row = repos.insertPolicyRule(app.db, { id, setId: body.set_id, - priority: body.priority, + priority, action: body.action, + enabled: body.enabled === false ? 0 : 1, listId, cidr, hostname, @@ -659,6 +710,44 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( return mapPolicyRule(row!, app.db) }) + app.patch<{ Params: { id: string } }>('/rules/:id', async (req) => { + const body = patchPolicyRuleBodySchema.parse(req.body) + const rule = repos.getPolicyRule(app.db, req.params.id) + if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404) + const updated = repos.updatePolicyRule(app.db, rule.id, { + enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0, + action: body.action, + comment: body.comment, + priority: body.priority, + }) + repos.bumpAgentsForSet(app.db, rule.setId) + return mapPolicyRule(updated!, app.db) + }) + + app.put<{ Params: { id: string } }>( + '/policy-sets/:id/rules/reorder', + async (req) => { + const body = reorderPolicyRulesBodySchema.parse(req.body) + const s = repos.getPolicySet(app.db, req.params.id) + if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) + try { + repos.reorderPolicyRules(app.db, s.id, body.ordered_ids) + } catch (err) { + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } + repos.bumpAgentsForSet(app.db, s.id) + return { + items: repos + .listPolicyRules(app.db, s.id) + .map((r) => mapPolicyRule(r, app.db)), + } + }, + ) + app.delete<{ Params: { id: string } }>('/rules/:id', async (req) => { const rule = repos.getPolicyRule(app.db, req.params.id) if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404) diff --git a/apps/api/src/services/policy/evaluate.ts b/apps/api/src/services/policy/evaluate.ts index c4631c9..a0e4f5a 100644 --- a/apps/api/src/services/policy/evaluate.ts +++ b/apps/api/src/services/policy/evaluate.ts @@ -44,6 +44,19 @@ function expandRule( return expandList(db, rule.listId) } +/** Effective mode = first enabled assigned set (by sort); default blacklist. */ +export function resolveAgentPolicyMode( + db: Db, + agentId: string, +): 'blacklist' | 'whitelist' { + const sets = repos + .listSetsForAgent(db, agentId) + .filter((s) => s.enabled === 1) + if (sets.length === 0) return 'blacklist' + const mode = sets[0]?.policyMode + return mode === 'whitelist' ? 'whitelist' : 'blacklist' +} + /** Evaluate allow/deny sets for an agent from assigned policy sets. */ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy { const agent = repos.getAgent(db, agentId) @@ -69,9 +82,12 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy { const denyCidrs = uniq(deny) const allowCidrs = uniq(allow) - const policyMode = (agent.policyMode === 'whitelist' - ? 'whitelist' - : 'blacklist') as 'blacklist' | 'whitelist' + const policyMode = resolveAgentPolicyMode(db, agentId) + + // Keep agent.policy_mode cache in sync for list/API compat + if (agent.policyMode !== policyMode) { + repos.updateAgent(db, agentId, { policyMode }) + } const payload = JSON.stringify({ generation: agent.policyGeneration, diff --git a/apps/api/src/services/policy/policy-mode.test.ts b/apps/api/src/services/policy/policy-mode.test.ts new file mode 100644 index 0000000..d7faeb1 --- /dev/null +++ b/apps/api/src/services/policy/policy-mode.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, afterAll } from 'vitest' +import { buildApp } from '../../app.js' +import type { AppConfig } from '../../config.js' + +const testConfig: AppConfig = { + databaseUrl: 'sqlite::memory:', + jwtSecret: 'test', + jwtTtlHours: 24, + serverPort: 8080, + staticDir: null, + logLevel: 'error', + authRequired: false, + authIssuer: 'https://auth.test', + authPortalUrl: 'http://localhost:5175', + publicBaseUrl: 'https://fw.example.com', + enrollSeed: 'test-seed', +} + +describe('policy set mode + rules', () => { + const appPromise = buildApp({ memory: true, config: testConfig }) + + afterAll(async () => { + const app = await appPromise + await app.close() + }) + + it('set policy_mode and reorder; disabled rules skipped in policy', async () => { + const app = await appPromise + await app.ready() + + const created = await app.inject({ + method: 'POST', + url: '/api/v1/policy-sets', + payload: { + name: 'WL set', + policy_mode: 'whitelist', + }, + }) + expect(created.statusCode).toBe(200) + const set = created.json() as { id: string; policy_mode: string } + expect(set.policy_mode).toBe('whitelist') + + const r1 = await app.inject({ + method: 'POST', + url: '/api/v1/rules', + payload: { + set_id: set.id, + action: 'allow', + cidr: '10.0.0.1/32', + }, + }) + expect(r1.statusCode).toBe(200) + const rule1 = r1.json() as { id: string; enabled: boolean; priority: number } + + const r2 = await app.inject({ + method: 'POST', + url: '/api/v1/rules', + payload: { + set_id: set.id, + action: 'allow', + cidr: '10.0.0.2/32', + }, + }) + const rule2 = r2.json() as { id: string } + + const reordered = await app.inject({ + method: 'PUT', + url: `/api/v1/policy-sets/${set.id}/rules/reorder`, + payload: { ordered_ids: [rule2.id, rule1.id] }, + }) + expect(reordered.statusCode).toBe(200) + const items = ( + reordered.json() as { items: { id: string; priority: number }[] } + ).items + expect(items[0]?.id).toBe(rule2.id) + expect(items[0]!.priority).toBeLessThan(items[1]!.priority) + + await app.inject({ + method: 'PATCH', + url: `/api/v1/rules/${rule1.id}`, + payload: { enabled: false }, + }) + + // enroll + approve agent, assign set + const enroll = await app.inject({ + method: 'POST', + url: '/v1/agent/enroll', + headers: { + 'content-type': 'application/json', + 'x-evofw-seed': 'test-seed', + }, + payload: { + name: 'mt-wl', + platform: 'linux', + token: 'evofw_policy_mode_token_abcdef12', + }, + }) + const agent = enroll.json() as { id: string } + await app.inject({ + method: 'POST', + url: `/api/v1/agents/${agent.id}/approve`, + }) + const assign = await app.inject({ + method: 'PUT', + url: `/api/v1/agents/${agent.id}/policy-sets`, + payload: { set_ids: [set.id] }, + }) + expect(assign.statusCode).toBe(200) + + const policy = await app.inject({ + method: 'GET', + url: '/v1/agent/policy', + headers: { + authorization: 'Bearer evofw_policy_mode_token_abcdef12', + }, + }) + expect(policy.statusCode).toBe(200) + const body = policy.json() as { + policy_mode: string + allow_cidrs: string[] + } + expect(body.policy_mode).toBe('whitelist') + expect(body.allow_cidrs).toContain('10.0.0.2/32') + expect(body.allow_cidrs).not.toContain('10.0.0.1/32') + expect(rule1.enabled).toBe(true) + }) +}) diff --git a/apps/web/src/components/agents/agent-platform-icon.tsx b/apps/web/src/components/agents/agent-platform-icon.tsx new file mode 100644 index 0000000..c82653a --- /dev/null +++ b/apps/web/src/components/agents/agent-platform-icon.tsx @@ -0,0 +1,38 @@ +import { Router, Terminal } from 'lucide-react' +import { cn } from '@evofw/ui/lib/utils' +import { Item, ItemMedia } from '@evofw/ui/components/item' + +type AgentPlatformIconProps = { + platform: string + className?: string +} + +/** + * KPI-style platform tile for agent rows. + * Preview DNA: https://reui.io/preview/base/stats-12 · settings-14 + */ +export function AgentPlatformIcon({ + platform, + className, +}: AgentPlatformIconProps) { + const isMt = platform === 'mikrotik' + const Icon = isMt ? Router : Terminal + return ( + + + + + + ) +} + +export function platformLabel(platform: string): string { + return platform === 'mikrotik' ? 'MikroTik' : 'Linux' +} diff --git a/apps/web/src/components/reui/sortable.tsx b/apps/web/src/components/reui/sortable.tsx new file mode 100644 index 0000000..86c1347 --- /dev/null +++ b/apps/web/src/components/reui/sortable.tsx @@ -0,0 +1,439 @@ +import * as React from "react" +import { + Children, + cloneElement, + createContext, + CSSProperties, + isValidElement, + ReactElement, + ReactNode, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useState, +} from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { + defaultDropAnimationSideEffects, + DndContext, + DragCancelEvent, + DragEndEvent, + DragOverlay, + DragStartEvent, + DropAnimation, + KeyboardSensor, + MeasuringStrategy, + Modifiers, + MouseSensor, + TouchSensor, + UniqueIdentifier, + useSensor, + useSensors, + type DraggableSyntheticListeners, +} from "@dnd-kit/core" +import { + arrayMove, + defaultAnimateLayoutChanges, + rectSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, + type AnimateLayoutChanges, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { createPortal } from "react-dom" + +import { cn } from "@evofw/ui/lib/utils" + +// Sortable Item Context +const SortableItemContext = createContext<{ + listeners: DraggableSyntheticListeners | undefined + isDragging?: boolean + disabled?: boolean +}>({ + listeners: undefined, + isDragging: false, + disabled: false, +}) + +const IsOverlayContext = createContext(false) + +const SortableInternalContext = createContext<{ + activeId: UniqueIdentifier | null + modifiers?: Modifiers +}>({ + activeId: null, + modifiers: undefined, +}) + +const animateLayoutChanges: AnimateLayoutChanges = (args) => + defaultAnimateLayoutChanges({ ...args, wasDragging: true }) + +const dropAnimationConfig: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: "0.4", + }, + }, + }), +} + +const MOUSE_SENSOR_OPTIONS = { activationConstraint: { distance: 10 } } +const TOUCH_SENSOR_OPTIONS = { + activationConstraint: { delay: 250, tolerance: 5 }, +} +const KEYBOARD_SENSOR_OPTIONS = { + coordinateGetter: sortableKeyboardCoordinates, +} +const MEASURING_CONFIG = { + droppable: { strategy: MeasuringStrategy.Always }, +} +const STRATEGY_MAP = { + horizontal: rectSortingStrategy, + grid: rectSortingStrategy, + vertical: verticalListSortingStrategy, +} as const + +// Multipurpose Sortable Component +export interface SortableCommitMeta { + event: DragEndEvent + activeIndex: number + overIndex: number + previousValue: T[] +} + +export interface SortableRootProps extends Omit< + useRender.ComponentProps<"div">, + "onDragStart" | "onDragEnd" | "children" +> { + value: T[] + onValueChange: (value: T[]) => void + getItemValue: (item: T) => string + children: ReactNode + onMove?: (event: { + event: DragEndEvent + activeIndex: number + overIndex: number + }) => void + onValueCommit?: (value: T[], meta: SortableCommitMeta) => void + strategy?: "horizontal" | "vertical" | "grid" + onDragStart?: (event: DragStartEvent) => void + onDragEnd?: (event: DragEndEvent) => void + onDragCancel?: (event: DragCancelEvent) => void + accessibility?: React.ComponentProps["accessibility"] + modifiers?: Modifiers +} + +function Sortable({ + value, + onValueChange, + getItemValue, + className, + render, + onMove, + onValueCommit, + strategy = "vertical", + onDragStart, + onDragEnd, + onDragCancel, + accessibility, + modifiers, + children, + ...props +}: SortableRootProps) { + const [activeId, setActiveId] = useState(null) + const [mounted, setMounted] = useState(false) + + useLayoutEffect(() => setMounted(true), []) + + const sensors = useSensors( + useSensor(MouseSensor, MOUSE_SENSOR_OPTIONS), + useSensor(TouchSensor, TOUCH_SENSOR_OPTIONS), + useSensor(KeyboardSensor, KEYBOARD_SENSOR_OPTIONS) + ) + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + setActiveId(event.active.id) + onDragStart?.(event) + }, + [onDragStart] + ) + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event + setActiveId(null) + onDragEnd?.(event) + + if (!over) return + + // Handle item reordering + const activeIndex = value.findIndex( + (item: T) => getItemValue(item) === active.id + ) + const overIndex = value.findIndex( + (item: T) => getItemValue(item) === over.id + ) + + if (activeIndex === -1 || overIndex === -1) return + + if (activeIndex !== overIndex) { + if (onMove) { + onMove({ event, activeIndex, overIndex }) + } else { + const newValue = arrayMove(value, activeIndex, overIndex) + onValueChange(newValue) + onValueCommit?.(newValue, { + event, + activeIndex, + overIndex, + previousValue: value, + }) + } + } + }, + [value, getItemValue, onValueChange, onMove, onDragEnd, onValueCommit] + ) + + const handleDragCancel = useCallback( + (event: DragCancelEvent) => { + setActiveId(null) + onDragCancel?.(event) + }, + [onDragCancel] + ) + + const itemIds = useMemo(() => { + const ids = value.map(getItemValue) + if (process.env.NODE_ENV !== "production") { + const seen = new Set() + for (const id of ids) { + if (seen.has(id)) { + console.warn( + `[Sortable] Duplicate item id "${id}". Item ids must be unique, or drag and drop will misbehave.` + ) + break + } + seen.add(id) + } + } + return ids + }, [value, getItemValue]) + + const contextValue = useMemo( + () => ({ activeId, modifiers }), + [activeId, modifiers] + ) + + const defaultProps = { + "data-slot": "sortable", + "data-dragging": activeId !== null, + className: cn(activeId !== null && "cursor-grabbing!", className), + children, + } + + // Find the active child for the overlay + const overlayContent = useMemo(() => { + if (!activeId) return null + let result: ReactNode = null + Children.forEach(children, (child) => { + if (isValidElement(child) && (child.props as any).value === activeId) { + result = cloneElement(child as ReactElement, { + ...(child.props as any), + className: cn((child.props as any).className, "z-50"), + }) + } + }) + return result + }, [activeId, children]) + + return ( + + + + {useRender({ + defaultTagName: "div", + render, + props: mergeProps<"div">(defaultProps, props), + })} + + {mounted && + createPortal( + + + {overlayContent} + + , + document.body + )} + + + ) +} + +export interface SortableItemProps extends useRender.ComponentProps<"div"> { + value: string + disabled?: boolean +} + +function SortableItem({ + value, + className, + render, + disabled, + ...props +}: SortableItemProps) { + const isOverlay = useContext(IsOverlayContext) + + const { + setNodeRef, + transform, + transition, + attributes, + listeners, + isDragging: isSortableDragging, + } = useSortable({ + id: value, + disabled: disabled || isOverlay, + animateLayoutChanges, + }) + + const style = { + transition, + transform: CSS.Transform.toString(transform), + } as CSSProperties + + const defaultProps = isOverlay + ? { + "data-slot": "sortable-item", + "data-value": value, + "data-dragging": true, + className: cn(className), + children: props.children, + } + : { + "data-slot": "sortable-item", + "data-value": value, + "data-dragging": isSortableDragging, + "data-disabled": disabled, + ref: setNodeRef, + style, + ...attributes, + className: cn( + isSortableDragging && "opacity-50 z-50", + disabled && "opacity-50", + className + ), + children: props.children, + } + + return ( + + {useRender({ + defaultTagName: "div", + render, + props: mergeProps<"div">(defaultProps, props), + })} + + ) +} + +export interface SortableItemHandleProps extends useRender.ComponentProps<"div"> { + cursor?: boolean +} + +function SortableItemHandle({ + className, + render, + cursor = true, + ...props +}: SortableItemHandleProps) { + const { listeners, isDragging, disabled } = useContext(SortableItemContext) + + const defaultProps = { + "data-slot": "sortable-item-handle", + "data-dragging": isDragging, + "data-disabled": disabled, + ...listeners, + className: cn( + cursor && (isDragging ? "cursor-grabbing!" : "cursor-grab!"), + className + ), + children: props.children, + } + + return useRender({ + defaultTagName: "div", + render, + props: mergeProps<"div">(defaultProps, props), + }) +} + +export interface SortableOverlayProps extends Omit< + React.ComponentProps, + "children" +> { + children?: ReactNode | ((params: { value: UniqueIdentifier }) => ReactNode) +} + +function SortableOverlay({ + children, + className, + ...props +}: SortableOverlayProps) { + const { activeId, modifiers } = useContext(SortableInternalContext) + const [mounted, setMounted] = useState(false) + + useLayoutEffect(() => setMounted(true), []) + + const content = + activeId && children + ? typeof children === "function" + ? children({ value: activeId }) + : children + : null + + if (!mounted) return null + + return createPortal( + + + {content} + + , + document.body + ) +} + +export { Sortable, SortableItem, SortableItemHandle, SortableOverlay } \ No newline at end of file diff --git a/apps/web/src/components/rules/policy-rules-sortable.tsx b/apps/web/src/components/rules/policy-rules-sortable.tsx new file mode 100644 index 0000000..548d8b2 --- /dev/null +++ b/apps/web/src/components/rules/policy-rules-sortable.tsx @@ -0,0 +1,231 @@ +import { useEffect, useState } from 'react' +import { + BanIcon, + GripVerticalIcon, + ShieldCheckIcon, + Trash2, +} from 'lucide-react' +import { toast } from 'sonner' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { PolicyRule } from '@evofw/shared' +import { + Sortable, + SortableItem, + SortableItemHandle, +} from '@/components/reui/sortable' +import { Badge } from '@/components/reui/badge' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Button } from '@evofw/ui/components/button' +import { Switch } from '@evofw/ui/components/switch' +import { Item, ItemMedia } from '@evofw/ui/components/item' +import { cn } from '@evofw/ui/lib/utils' +import { apiFetch } from '@/lib/api' + +/** + * Ordered firewall rules — ReUI Sortable + settings-8 DNA. + * Preview: https://reui.io/preview/base/components/c-sortable-5 + * · https://reui.io/preview/base/settings-8 + * Docs: https://reui.io/docs/components/base/sortable + */ + +function ruleTarget(r: PolicyRule): string { + if (r.cidr) return r.cidr + if (r.hostname) return r.hostname + if (r.list_id) return `list:${r.list_id.slice(0, 8)}…` + return '—' +} + +type PolicyRulesSortableProps = { + setId: string + rules: PolicyRule[] + policyMode: 'blacklist' | 'whitelist' + onDelete: (id: string) => void +} + +export function PolicyRulesSortable({ + setId, + rules: rulesProp, + policyMode, + onDelete, +}: PolicyRulesSortableProps) { + const qc = useQueryClient() + const [items, setItems] = useState(rulesProp) + + useEffect(() => { + setItems(rulesProp) + }, [rulesProp]) + + const reorder = useMutation({ + mutationFn: (ordered_ids: string[]) => + apiFetch(`/api/v1/policy-sets/${setId}/rules/reorder`, { + method: 'PUT', + body: JSON.stringify({ ordered_ids }), + }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['policy-sets', setId] }) + void qc.invalidateQueries({ queryKey: ['policy-sets'] }) + }, + onError: (e: Error, _vars, context) => { + toast.error(e.message) + if (context && typeof context === 'object' && 'prev' in context) { + setItems((context as { prev: PolicyRule[] }).prev) + } + }, + onMutate: async (ordered_ids) => { + const prev = items + const byId = new Map(items.map((r) => [r.id, r])) + setItems(ordered_ids.map((id) => byId.get(id)!).filter(Boolean)) + return { prev } + }, + }) + + const toggle = useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + apiFetch(`/api/v1/rules/${id}`, { + method: 'PATCH', + body: JSON.stringify({ enabled }), + }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['policy-sets', setId] }) + void qc.invalidateQueries({ queryKey: ['policy-sets'] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + const isWl = policyMode === 'whitelist' + + return ( + + + + + {isWl ? 'DROP' : 'ACCEPT'} + + + {isWl + ? 'По умолчанию DROP — ниже только allow-правила пропускают трафик' + : 'По умолчанию ACCEPT — ниже deny-правила блокируют адреса'} + + + + + {items.length === 0 ? ( + + + Нет правил — добавьте CIDR, список или hostname + + + ) : ( + + + Правила + + Перетащите для порядка · Switch — вкл/выкл + + + + r.id} + onValueCommit={(next, meta) => { + reorder.mutate( + next.map((r) => r.id), + { onError: () => setItems(meta.previousValue) }, + ) + }} + className="flex flex-col" + > + {items.map((r) => { + const enabled = r.enabled !== false + const isDeny = r.action === 'deny' + return ( + + + + + + + + {isDeny ? ( + + ) : ( + + )} + + + + + + + {ruleTarget(r)} + + + {r.action} + + {!enabled ? ( + + Выкл + + ) : null} + + {r.comment ? ( + + {r.comment} + + ) : null} + + + + toggle.mutate({ id: r.id, enabled: v }) + } + aria-label={enabled ? 'Выключить' : 'Включить'} + /> + + onDelete(r.id)} + > + + + + ) + })} + + + + )} + + ) +} diff --git a/apps/web/src/routes/_auth/agents/$id.tsx b/apps/web/src/routes/_auth/agents/$id.tsx index af6a57b..2b03410 100644 --- a/apps/web/src/routes/_auth/agents/$id.tsx +++ b/apps/web/src/routes/_auth/agents/$id.tsx @@ -18,6 +18,11 @@ import { FrameTitle, } from '@/components/reui/frame' import { StatusBadge } from '@/components/status-badge' +import { Badge } from '@/components/reui/badge' +import { + AgentPlatformIcon, + platformLabel, +} from '@/components/agents/agent-platform-icon' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGrid } from '@/components/reui/data-grid/data-grid' @@ -67,16 +72,14 @@ function AgentDetailPage() { const assignedIds = selectedSets ?? assignedQ.data?.items.map((i) => i.set_id) ?? [] - const patchMode = useMutation({ - mutationFn: (policy_mode: 'blacklist' | 'whitelist') => - apiFetch(`/api/v1/agents/${id}`, { - method: 'PATCH', - body: JSON.stringify({ policy_mode }), - }), + const revoke = useMutation({ + mutationFn: () => + apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }), onSuccess: () => { - toast.success('Режим обновлён') + toast.success('Агент отозван') void qc.invalidateQueries({ queryKey: ['agents'] }) }, + onError: (e: Error) => toast.error(e.message), }) const addOverride = useMutation({ @@ -207,10 +210,21 @@ function AgentDetailPage() { + + {a.status === 'approved' ? ( + revoke.mutate()} + disabled={revoke.isPending} + > + Revoke + + ) : null} - Политика + Режим фильтра - blacklist = deny set; whitelist = allow set + default drop + Задаётся наборами правил (не на агенте). Все назначенные + наборы должны иметь один режим. - - - patchMode.mutate('blacklist')} - > - Blacklist - - patchMode.mutate('whitelist')} - > - Whitelist - - + + + {a.policy_mode === 'whitelist' + ? 'Белый список' + : 'Чёрный список'} + + } + > + Открыть правила + @@ -292,7 +308,7 @@ function AgentDetailPage() { Наборы правил - Можно назначить несколько — мержатся при sync + Можно назначить несколько — мержатся при sync (один режим) diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx index 182359a..08de1eb 100644 --- a/apps/web/src/routes/_auth/agents/index.tsx +++ b/apps/web/src/routes/_auth/agents/index.tsx @@ -1,8 +1,8 @@ -import { createFileRoute, Link } from '@tanstack/react-router' +import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { Check, Copy, Plus, Trash2 } from 'lucide-react' -import { useCallback, useMemo, useState } from 'react' +import { Check, Copy, Pencil, Plus, Trash2 } from 'lucide-react' +import { useCallback, useMemo, useState, type MouseEvent } from 'react' import type { ColumnDef } from '@tanstack/react-table' import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import { @@ -18,6 +18,10 @@ import { import { StatusBadge } from '@/components/status-badge' import { ConfirmDialog } from '@/components/confirm-dialog' import { AddAgentSheet } from '@/components/agents/add-agent-sheet' +import { + AgentPlatformIcon, + platformLabel, +} from '@/components/agents/agent-platform-icon' import { agentsQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' @@ -32,15 +36,15 @@ import type { Agent } from '@evofw/shared' /** * Agents list — ResourcePage (Frame + tabs + Filters + DataGrid). * Preview: https://reui.io/preview/base/data-grid-filtering-2 - * Copyable install: https://reui.io/preview/base/settings-14 + * Row icon + copyable install: https://reui.io/preview/base/settings-14 · stats-12 * Empty: https://reui.io/preview/base/empty-state-7 - * Create Sheet: https://reui.io/preview/base/sheet-1 · sheet-8 */ export const Route = createFileRoute('/_auth/agents/')({ component: AgentsPage, }) function AgentsPage() { + const navigate = useNavigate() const qc = useQueryClient() const agentsQ = useQuery(agentsQueryOptions()) const { copyToClipboard } = useCopyToClipboard() @@ -59,16 +63,6 @@ function AgentsPage() { onError: (e: Error) => toast.error(e.message), }) - const revoke = useMutation({ - mutationFn: (id: string) => - apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }), - onSuccess: () => { - toast.success('Агент отозван') - void qc.invalidateQueries({ queryKey: ['agents'] }) - }, - onError: (e: Error) => toast.error(e.message), - }) - const remove = useMutation({ mutationFn: (id: string) => apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }), @@ -127,7 +121,8 @@ function AgentsPage() { }, []) const handleCopyCurl = useCallback( - (curl: string) => { + (curl: string, e?: MouseEvent) => { + e?.stopPropagation() if (!curl) return copyToClipboard(curl) toast.success('Скопировано') @@ -142,25 +137,19 @@ function AgentsPage() { header: ({ column }) => ( ), - cell: ({ row }) => ( - - - - ), - }, - { - accessorKey: 'platform', - header: ({ column }) => ( - - ), + cell: ({ row }) => { + const a = row.original + return ( + + + + + ) + }, }, { accessorKey: 'status', @@ -188,7 +177,7 @@ function AgentsPage() { size="sm" variant="outline" className="max-w-[14rem] font-mono text-xs" - onClick={() => handleCopyCurl(curl)} + onClick={(e) => handleCopyCurl(curl, e)} /> } > @@ -202,12 +191,6 @@ function AgentsPage() { ) }, }, - { - accessorKey: 'policy_mode', - header: ({ column }) => ( - - ), - }, { accessorKey: 'last_seen_at', header: ({ column }) => ( @@ -229,36 +212,41 @@ function AgentsPage() { {a.status === 'pending' ? ( approve.mutate(a.id)} + size="icon-sm" + variant="ghost" + aria-label="Approve" disabled={approve.isPending} + onClick={(e) => { + e.stopPropagation() + approve.mutate(a.id) + }} > - - Approve - - ) : null} - } - > - Открыть - - {a.status === 'approved' ? ( - revoke.mutate(a.id)} - > - Revoke + ) : null} + { + e.stopPropagation() + void navigate({ + to: '/agents/$id', + params: { id: a.id }, + }) + }} + > + + setDeleteId(a.id)} + onClick={(e) => { + e.stopPropagation() + setDeleteId(a.id) + }} > @@ -267,7 +255,7 @@ function AgentsPage() { }, }, ], - [approve, revoke, handleCopyCurl], + [approve, handleCopyCurl, navigate], ) const addButton = ( @@ -296,6 +284,9 @@ function AgentsPage() { onFiltersChange={setFilters} onClearFilters={() => setFilters([])} getFilterFieldValue={getFilterFieldValue} + onRowClick={(row) => + void navigate({ to: '/agents/$id', params: { id: row.id } }) + } tabs={[ { id: 'all', label: 'Все' }, { id: 'invited', label: 'Invited' }, diff --git a/apps/web/src/routes/_auth/rules/$setId.tsx b/apps/web/src/routes/_auth/rules/$setId.tsx index c4d00b8..176a526 100644 --- a/apps/web/src/routes/_auth/rules/$setId.tsx +++ b/apps/web/src/routes/_auth/rules/$setId.tsx @@ -5,16 +5,16 @@ import { ListIcon, ShieldIcon, UsersIcon, - Trash2, } from 'lucide-react' -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import type { ColumnDef, RowSelectionState } from '@tanstack/react-table' -import type { Filter, FilterFieldConfig } from '@/components/reui/filters' -import { PageShell, DetailPanel, ResourcePage } from '@/components/reui-kit' +import { PageShell, DetailPanel } from '@/components/reui-kit' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { StatusBadge } from '@/components/status-badge' import { ConfirmDialog } from '@/components/confirm-dialog' +import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable' +import { Badge } from '@/components/reui/badge' import { agentsQueryOptions, listsQueryOptions, @@ -44,11 +44,8 @@ import { } from '@evofw/ui/components/sheet' import { DataGrid } from '@/components/reui/data-grid/data-grid' import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' -import { - getCoreRowModel, - useReactTable, -} from '@tanstack/react-table' -import type { Agent, PolicyRule } from '@evofw/shared' +import { getCoreRowModel, useReactTable } from '@tanstack/react-table' +import type { Agent } from '@evofw/shared' import { Skeleton } from '@evofw/ui/components/skeleton' import { Frame, @@ -65,8 +62,9 @@ export const Route = createFileRoute('/_auth/rules/$setId')({ type SourceKind = 'list' | 'cidr' | 'hostname' /** - * Policy set detail — DetailPanel + ResourcePage rules + agents DataGrid. - * Preview: https://reui.io/preview/base/data-grid-filtering-2 · stats-12 · sheet-8 + * Policy set detail — Sortable rules (ReUI PRO) + agents. + * Preview: https://reui.io/preview/base/components/c-sortable-5 · settings-8 · settings-3 + * Docs: https://reui.io/docs/components/base/sortable */ function PolicySetDetailPage() { const { setId } = Route.useParams() @@ -77,14 +75,12 @@ function PolicySetDetailPage() { const listsQ = useQuery(listsQueryOptions()) const [ruleOpen, setRuleOpen] = useState(false) - const [priority, setPriority] = useState('100') const [action, setAction] = useState<'allow' | 'deny'>('deny') const [source, setSource] = useState('cidr') const [listId, setListId] = useState('') const [cidr, setCidr] = useState('') const [hostname, setHostname] = useState('') const [selectedAgents, setSelectedAgents] = useState(null) - const [ruleFilters, setRuleFilters] = useState([]) const [deleteRuleId, setDeleteRuleId] = useState(null) const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? [] @@ -101,7 +97,11 @@ function PolicySetDetailPage() { }, [assignedIds]) const patchSet = useMutation({ - mutationFn: (body: { enabled?: boolean; name?: string }) => + mutationFn: (body: { + enabled?: boolean + name?: string + policy_mode?: 'blacklist' | 'whitelist' + }) => apiFetch(`/api/v1/policy-sets/${setId}`, { method: 'PATCH', body: JSON.stringify(body), @@ -109,6 +109,7 @@ function PolicySetDetailPage() { onSuccess: () => { toast.success('Набор обновлён') void qc.invalidateQueries({ queryKey: ['policy-sets'] }) + void qc.invalidateQueries({ queryKey: ['agents'] }) }, onError: (e: Error) => toast.error(e.message), }) @@ -145,7 +146,6 @@ function PolicySetDetailPage() { mutationFn: () => { const body: Record = { set_id: setId, - priority: Number(priority), action, } if (source === 'list') body.list_id = listId @@ -179,112 +179,6 @@ function PolicySetDetailPage() { const rules = rulesQ.data?.items ?? [] - const ruleFilterFields: FilterFieldConfig[] = useMemo( - () => [ - { - key: 'action', - label: 'Action', - type: 'select', - options: [ - { value: 'deny', label: 'deny' }, - { value: 'allow', label: 'allow' }, - ], - }, - { - key: 'source', - label: 'Источник', - type: 'select', - options: [ - { value: 'list', label: 'list' }, - { value: 'cidr', label: 'CIDR' }, - { value: 'hostname', label: 'DNS' }, - ], - }, - ], - [], - ) - - const getRuleFilterValue = useCallback((item: PolicyRule, field: string) => { - if (field === 'action') return item.action - if (field === 'source') { - if (item.hostname) return 'hostname' - if (item.cidr) return 'cidr' - return 'list' - } - return undefined - }, []) - - const ruleColumns: ColumnDef[] = useMemo( - () => [ - { - accessorKey: 'priority', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - {row.original.priority} - ), - }, - { - accessorKey: 'action', - header: ({ column }) => ( - - ), - cell: ({ row }) => , - }, - { - id: 'source', - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const r = row.original - if (r.hostname) { - return ( - - ) - } - if (r.cidr) { - return - } - return ( - - ) - }, - }, - { - id: 'actions', - enableSorting: false, - header: () => Действия, - cell: ({ row }) => ( - - setDeleteRuleId(row.original.id)} - > - - - - ), - }, - ], - [], - ) - const agentColumns: ColumnDef[] = useMemo( () => [ { @@ -377,6 +271,8 @@ function PolicySetDetailPage() { } const set = setQ.data + const policyMode = + set.policy_mode === 'whitelist' ? 'whitelist' : 'blacklist' return ( @@ -425,7 +321,9 @@ function PolicySetDetailPage() { { id: 'status', icon: , - iconClassName: set.enabled ? 'text-success' : 'text-muted-foreground', + iconClassName: set.enabled + ? 'text-success' + : 'text-muted-foreground', label: 'Статус', description: set.enabled ? 'Включён' : 'Выключен', hint: set.enabled ? 'active' : 'disabled', @@ -433,37 +331,69 @@ function PolicySetDetailPage() { ]} /> + + + + Режим фильтра + + Blacklist: блокировать deny. Whitelist: пропускать только + allow, остальное (forward) — DROP. + + + + + + patchSet.mutate({ policy_mode: 'blacklist' }) + } + > + Чёрный список + + + patchSet.mutate({ policy_mode: 'whitelist' }) + } + > + Белый список + + + {policyMode} + + + + + + - r.id} - filterFields={ruleFilterFields} - filters={ruleFilters} - onFiltersChange={setRuleFilters} - onClearFilters={() => setRuleFilters([])} - getFilterFieldValue={getRuleFilterValue} - isLoading={rulesQ.isLoading} - emptyState={{ - title: 'Нет правил', - description: 'Добавьте CIDR, DNS или IP-список.', - action: ( - setRuleOpen(true)}> - Правило - - ), - }} + setDeleteRuleId(id)} /> @@ -518,18 +448,10 @@ function PolicySetDetailPage() { Новое правило - Один источник: список, CIDR или DNS-имя + Один источник: список, CIDR или DNS-имя (priority — в конец) - - - Priority - setPriority(e.target.value)} - /> - + Action - + @@ -547,7 +469,7 @@ function PolicySetDetailPage() { - + Источник {source === 'list' ? ( - + Список ) : null} {source === 'cidr' ? ( - + CIDR ) : null} {source === 'hostname' ? ( - + DNS-имя ), }, + { + accessorKey: 'policy_mode', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.policy_mode === 'whitelist' + ? 'whitelist' + : 'blacklist'} + + ), + }, { accessorKey: 'rules_count', header: ({ column }) => ( diff --git a/docs/agents.md b/docs/agents.md index 1a1d118..65df0d7 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -56,12 +56,12 @@ Install RSC: 2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW`. 3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy.rsc` → `/import` (списки + режим). -**Blacklist:** `drop` по `EVOFW_DENY` в `input` и `forward`. -**Whitelist:** `accept` по `EVOFW_ALLOW` + catch-all `drop` только в `forward` (input не закрывается — Winbox/SSH). +**Режим фильтра** задаётся на **наборе правил** (`/rules`), не на агенте: -Legacy: скачайте `/v1/agent/mikrotik-install.rsc`, задайте globals `EvofwCpUrl`, `EvofwSeed`, `EvofwName`, опционально `EvofwInstallLinkId`, затем `/import`. +- **blacklist** — по умолчанию ACCEPT; deny-CIDR блокируются +- **whitelist** — по умолчанию DROP (forward); только allow-CIDR -Одобрите агента в UI — после Approve sync начнёт применять политику. +Все наборы, назначенные агенту, должны иметь один режим. ## Force sync diff --git a/packages/db/migrations/005_policy_set_mode.sql b/packages/db/migrations/005_policy_set_mode.sql new file mode 100644 index 0000000..26f48dc --- /dev/null +++ b/packages/db/migrations/005_policy_set_mode.sql @@ -0,0 +1,15 @@ +-- Mode on policy sets + per-rule enabled + +ALTER TABLE policy_sets ADD COLUMN policy_mode TEXT NOT NULL DEFAULT 'blacklist'; + +ALTER TABLE policy_rules ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1; + +-- Backfill set mode from agents that use the set (prefer whitelist if any agent has it) +UPDATE policy_sets +SET policy_mode = 'whitelist' +WHERE id IN ( + SELECT DISTINCT aps.set_id + FROM agent_policy_sets aps + INNER JOIN agents a ON a.id = aps.agent_id + WHERE a.policy_mode = 'whitelist' +); diff --git a/packages/db/src/repositories/index.ts b/packages/db/src/repositories/index.ts index fb65468..e9c8032 100644 --- a/packages/db/src/repositories/index.ts +++ b/packages/db/src/repositories/index.ts @@ -206,6 +206,7 @@ export function listSetsForAgent(db: Db, agentId: string) { name: policySets.name, description: policySets.description, enabled: policySets.enabled, + policyMode: policySets.policyMode, }) .from(agentPolicySets) .innerJoin(policySets, eq(agentPolicySets.setId, policySets.id)) @@ -214,8 +215,24 @@ export function listSetsForAgent(db: Db, agentId: string) { .all() } -/** Replace agent↔set assignments; set_ids order = sort. */ +/** Replace agent↔set assignments; set_ids order = sort. All sets must share policy_mode. */ export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) { + if (setIds.length > 0) { + const modes = new Set() + for (const setId of setIds) { + const s = getPolicySet(db, setId) + if (!s) throw new Error(`policy set not found: ${setId}`) + modes.add(s.policyMode === 'whitelist' ? 'whitelist' : 'blacklist') + } + if (modes.size > 1) { + throw new Error( + 'все наборы агента должны иметь один режим (blacklist или whitelist)', + ) + } + const mode = [...modes][0] ?? 'blacklist' + updateAgent(db, agentId, { policyMode: mode }) + } + db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run() setIds.forEach((setId, i) => { db.insert(agentPolicySets) @@ -270,6 +287,7 @@ export function listPolicyRulesForAgent(db: Db, agentId: string) { .all() return rules + .filter((r) => r.enabled !== 0) .map((r) => ({ ...r, _setSort: sortBySet.get(r.setId) ?? 0, @@ -289,6 +307,57 @@ export function insertPolicyRule( return getPolicyRule(db, row.id) } +export function updatePolicyRule( + db: Db, + id: string, + patch: Partial, +) { + db.update(policyRules) + .set({ ...patch, updatedAt: new Date().toISOString() }) + .where(eq(policyRules.id, id)) + .run() + return getPolicyRule(db, id) +} + +/** Renumber priorities 10, 20, … in given order. */ +export function reorderPolicyRules( + db: Db, + setId: string, + orderedIds: string[], +) { + const existing = listPolicyRules(db, setId) + const existingIds = new Set(existing.map((r) => r.id)) + if ( + orderedIds.length !== existing.length || + orderedIds.some((id) => !existingIds.has(id)) + ) { + throw new Error('ordered_ids must list every rule in the set exactly once') + } + // Temporary priorities to avoid UNIQUE collisions + orderedIds.forEach((id, i) => { + db.update(policyRules) + .set({ priority: 9000 + i, updatedAt: new Date().toISOString() }) + .where(eq(policyRules.id, id)) + .run() + }) + orderedIds.forEach((id, i) => { + db.update(policyRules) + .set({ + priority: (i + 1) * 10, + updatedAt: new Date().toISOString(), + }) + .where(eq(policyRules.id, id)) + .run() + }) +} + +export function nextRulePriority(db: Db, setId: string): number { + const rows = listPolicyRules(db, setId) + if (rows.length === 0) return 10 + const max = Math.max(...rows.map((r) => r.priority)) + return Math.min(10000, max + 10) +} + export function deletePolicyRule(db: Db, id: string) { db.delete(policyRules).where(eq(policyRules.id, id)).run() } @@ -535,6 +604,9 @@ export const repos = { listPolicyRulesForAgent, getPolicyRule, insertPolicyRule, + updatePolicyRule, + reorderPolicyRules, + nextRulePriority, deletePolicyRule, listHostnameRules, listResolvedForRule, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3c1abab..c39fc74 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -84,6 +84,7 @@ export const policySets = sqliteTable('policy_sets', { name: text('name').notNull(), description: text('description'), enabled: integer('enabled').notNull().default(1), + policyMode: text('policy_mode').notNull().default('blacklist'), // blacklist | whitelist createdAt: text('created_at') .notNull() .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), @@ -118,6 +119,7 @@ export const policyRules = sqliteTable( .references(() => policySets.id, { onDelete: 'cascade' }), priority: integer('priority').notNull(), action: text('action').notNull(), // allow | deny + enabled: integer('enabled').notNull().default(1), listId: text('list_id').references(() => ipLists.id, { onDelete: 'cascade' }), cidr: text('cidr'), hostname: text('hostname'), diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index ac47297..5951665 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -61,6 +61,7 @@ export const policyRuleSchema = z.object({ set_id: z.string(), priority: z.number().int(), action: policyActionSchema, + enabled: z.boolean().optional().default(true), list_id: z.string().nullable().optional(), cidr: z.string().nullable().optional(), hostname: z.string().nullable().optional(), @@ -75,6 +76,7 @@ export const policySetSchema = z.object({ name: z.string(), description: z.string().nullable().optional(), enabled: z.boolean(), + policy_mode: policyModeSchema, rules_count: z.number().int().optional(), agents_count: z.number().int().optional(), created_at: z.string(), @@ -102,19 +104,22 @@ export const createPolicySetBodySchema = z.object({ name: z.string().min(1), description: z.string().nullable().optional(), enabled: z.boolean().optional().default(true), + policy_mode: policyModeSchema.optional().default('blacklist'), }) export const patchPolicySetBodySchema = z.object({ name: z.string().min(1).optional(), description: z.string().nullable().optional(), enabled: z.boolean().optional(), + policy_mode: policyModeSchema.optional(), }) export const createPolicyRuleBodySchema = z .object({ set_id: z.string().min(1), - priority: z.number().int().min(1).max(10000), + priority: z.number().int().min(1).max(10000).optional(), action: policyActionSchema, + enabled: z.boolean().optional().default(true), list_id: z.string().nullable().optional(), cidr: z.string().nullable().optional(), hostname: z.string().nullable().optional(), @@ -132,6 +137,17 @@ export const createPolicyRuleBodySchema = z } }) +export const patchPolicyRuleBodySchema = z.object({ + enabled: z.boolean().optional(), + action: policyActionSchema.optional(), + comment: z.string().nullable().optional(), + priority: z.number().int().min(1).max(10000).optional(), +}) + +export const reorderPolicyRulesBodySchema = z.object({ + ordered_ids: z.array(z.string()).min(1), +}) + export const putAgentPolicySetsBodySchema = z.object({ set_ids: z.array(z.string()), })
+ {isWl + ? 'По умолчанию DROP — ниже только allow-правила пропускают трафик' + : 'По умолчанию ACCEPT — ниже deny-правила блокируют адреса'} +