From 1f7273f38ded604d4b20703ffd267bb358edeab6 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 23 Jul 2026 10:52:28 +0700 Subject: [PATCH] feat(api): unify policy handling with default action updates - Updated `evofw-firewall.sh` and related scripts to replace `policy_mode` with `default_action`, enhancing clarity and consistency in policy management. - Adjusted agent routes and evaluation logic to accommodate the new default action structure, ensuring backward compatibility with legacy modes. - Enhanced tests to validate the new default action behavior and its integration within the agent policy framework. - Refactored related components in the web interface to align with the updated policy handling, improving user experience and reducing confusion around policy modes. --- apps/api/src/agent-scripts/evofw-firewall.sh | 41 ++- apps/api/src/routes/agent.ts | 10 +- apps/api/src/routes/control.ts | 140 ++++--- apps/api/src/services/install-links.test.ts | 4 + apps/api/src/services/lists/refresh.ts | 34 +- apps/api/src/services/policy/evaluate.ts | 138 +++++-- .../src/services/policy/mikrotik-rsc.test.ts | 70 ++-- apps/api/src/services/policy/mikrotik-rsc.ts | 29 +- .../src/services/policy/policy-mode.test.ts | 173 +++++---- .../agents/agent-effective-cidrs.tsx | 115 ++++++ .../components/agents/agent-facts-panel.tsx | 128 +++++++ .../agents/agent-policy-sets-sortable.tsx | 54 +-- .../components/agents/agent-policy-trace.tsx | 126 +++++++ apps/web/src/components/reui/autocomplete.tsx | 346 ++++++++++++++++++ .../components/rules/policy-mode-toggle.tsx | 69 ---- .../rules/policy-rules-sortable.tsx | 16 +- .../src/components/rules/policy-set-icon.tsx | 9 +- apps/web/src/queries/index.ts | 21 +- apps/web/src/routes/_auth/agents/$id.tsx | 232 ++++-------- apps/web/src/routes/_auth/lists/index.tsx | 60 ++- apps/web/src/routes/_auth/rules/$setId.tsx | 20 +- apps/web/src/routes/_auth/rules/index.tsx | 23 +- docs/agents.md | 10 +- docs/architecture.md | 10 +- docs/integrate-evobgp.md | 17 +- packages/db/migrations/007_default_action.sql | 64 ++++ packages/db/src/repositories/index.ts | 20 +- packages/db/src/schema.ts | 6 +- packages/shared/src/contracts.ts | 103 +++++- packages/ui/src/components/scroll-area.tsx | 2 - 30 files changed, 1469 insertions(+), 621 deletions(-) create mode 100644 apps/web/src/components/agents/agent-effective-cidrs.tsx create mode 100644 apps/web/src/components/agents/agent-facts-panel.tsx create mode 100644 apps/web/src/components/agents/agent-policy-trace.tsx create mode 100644 apps/web/src/components/reui/autocomplete.tsx delete mode 100644 apps/web/src/components/rules/policy-mode-toggle.tsx create mode 100644 packages/db/migrations/007_default_action.sql diff --git a/apps/api/src/agent-scripts/evofw-firewall.sh b/apps/api/src/agent-scripts/evofw-firewall.sh index 50f923f..911c149 100644 --- a/apps/api/src/agent-scripts/evofw-firewall.sh +++ b/apps/api/src/agent-scripts/evofw-firewall.sh @@ -57,7 +57,12 @@ parse_policy() { local f="$1" if command -v jq >/dev/null 2>&1; then HASH=$(jq -r '.hash // empty' "$f") - MODE=$(jq -r '.policy_mode // "blacklist"' "$f") + DEFAULT_ACTION=$(jq -r '.default_action // empty' "$f") + if [[ -z "$DEFAULT_ACTION" ]]; then + local legacy + legacy=$(jq -r '.policy_mode // "blacklist"' "$f") + if [[ "$legacy" == "whitelist" ]]; then DEFAULT_ACTION=drop; else DEFAULT_ACTION=accept; fi + fi mapfile -t DENY < <(jq -r '.deny_cidrs[]? // empty' "$f") mapfile -t ALLOW < <(jq -r '.allow_cidrs[]? // empty' "$f") return 0 @@ -67,7 +72,10 @@ parse_policy() { import json,sys d=json.load(open(sys.argv[1],encoding="utf-8")) print(f'HASH={d.get("hash") or ""}') -print(f'MODE={d.get("policy_mode") or "blacklist"}') +da=d.get("default_action") or "" +if not da: + da="drop" if d.get("policy_mode")=="whitelist" else "accept" +print(f'DEFAULT_ACTION={da}') print("DENY=("+" ".join(json.dumps(x) for x in (d.get("deny_cidrs") or []))+")") print("ALLOW=("+" ".join(json.dumps(x) for x in (d.get("allow_cidrs") or []))+")") PY @@ -78,12 +86,12 @@ PY exit 1 } -HASH=""; MODE=blacklist; DENY=(); ALLOW=() +HASH=""; DEFAULT_ACTION=accept; DENY=(); ALLOW=() parse_policy "$POLICY_FILE" # Empty deny/allow is valid — agent may have no rule sets yet. DENY=("${DENY[@]+"${DENY[@]}"}") ALLOW=("${ALLOW[@]+"${ALLOW[@]}"}") -log "mode=$MODE deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH" +log "default_action=$DEFAULT_ACTION deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH" PACKETS_DROPPED=0 PACKETS_ACCEPTED=0 @@ -148,15 +156,19 @@ apply_nft() { ((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}" nft delete chain "$table" "$name" input 2>/dev/null || true - if [[ "$MODE" == "whitelist" ]]; then + # Unified chain: deny → allow → default_action + if [[ "$DEFAULT_ACTION" == "drop" ]]; then nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }' - nft add rule "$table" "$name" input ct state established,related counter accept - nft add rule "$table" "$name" input iif lo counter accept - nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept - nft add rule "$table" "$name" input counter drop else nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }' - nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop + fi + nft add rule "$table" "$name" input ct state established,related counter accept + nft add rule "$table" "$name" input iif lo counter accept + nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop + 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 nft add rule "$table" "$name" input counter accept fi KERNEL_METHOD=nft @@ -173,11 +185,12 @@ apply_ipset() { for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; ipset add "$aset" "$p" -exist; n=$((n+1)); done iptables -D INPUT -m set --match-set "$dset" src -j DROP 2>/dev/null || true iptables -D INPUT -m set --match-set "$aset" src -j ACCEPT 2>/dev/null || true - if [[ "$MODE" == "whitelist" ]]; then - iptables -I INPUT -m set --match-set "$aset" src -j ACCEPT + iptables -D INPUT -j DROP 2>/dev/null || true + # Unified: deny first, then allow, then optional default drop + iptables -I INPUT -m set --match-set "$dset" src -j DROP + iptables -I INPUT 2 -m set --match-set "$aset" src -j ACCEPT + if [[ "$DEFAULT_ACTION" == "drop" ]]; then iptables -A INPUT -j DROP 2>/dev/null || true - else - iptables -I INPUT -m set --match-set "$dset" src -j DROP fi KERNEL_METHOD=ipset APPLIED=$n diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index 1cc5a7c..2494f82 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -122,7 +122,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( tokenPrefix: body.token.slice(0, 12), tokenHash, status: 'pending', - policyMode: 'blacklist', + defaultAction: 'accept', policyGeneration: 1, clientVersion: body.client_version ?? null, settingsJson: '{}', @@ -149,17 +149,19 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( return { generation: policy.generation, hash: policy.hash, + apply_version: policy.applyVersion, + default_action: policy.defaultAction, policy_mode: policy.policyMode, deny_cidrs: policy.denyCidrs, allow_cidrs: policy.allowCidrs, sync_interval_sec: policy.syncIntervalSec, - // compat aliases for simple clients + // compat: prefixes = deny when default accept, else allow (legacy single-bag clients) prefixes: - policy.policyMode === 'blacklist' + policy.defaultAction === 'accept' ? policy.denyCidrs : policy.allowCidrs, total: - policy.policyMode === 'blacklist' + policy.defaultAction === 'accept' ? policy.denyCidrs.length : policy.allowCidrs.length, } diff --git a/apps/api/src/routes/control.ts b/apps/api/src/routes/control.ts index ca695ec..b67a4e3 100644 --- a/apps/api/src/routes/control.ts +++ b/apps/api/src/routes/control.ts @@ -23,7 +23,7 @@ import { deleteListEntry, mapListDetail, } from '../services/lists/entries.js' -import { evaluateAgentPolicy } from '../services/policy/evaluate.js' +import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js' import { resolveAndStoreHostnameRule, resolveHostnameToCidrs, @@ -41,6 +41,8 @@ function mapAgent( a: NonNullable>, opts?: { installCurl?: string | null; installLinkId?: string | null }, ) { + const defaultAction = + a.defaultAction === 'drop' ? ('drop' as const) : ('accept' as const) return { id: a.id, name: a.name, @@ -48,7 +50,8 @@ function mapAgent( platform: a.platform, token_prefix: a.tokenPrefix, status: a.status, - policy_mode: a.policyMode, + default_action: defaultAction, + policy_mode: defaultAction === 'drop' ? ('whitelist' as const) : ('blacklist' as const), policy_generation: a.policyGeneration, last_seen_at: a.lastSeenAt, last_seen_ip: a.lastSeenIp, @@ -77,8 +80,6 @@ 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, @@ -185,7 +186,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( tokenPrefix: inviteToken.slice(0, 12), tokenHash: hashToken(inviteToken), status: 'invited', - policyMode: 'blacklist', + defaultAction: 'accept', policyGeneration: 1, clientVersion: null, settingsJson: '{}', @@ -252,16 +253,45 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( return mapAgent(a) }) - app.get<{ Params: { id: string } }>('/agents/:id/preview', async (req) => { + app.get<{ + Params: { id: string } + Querystring: { limit_cidrs?: string } + }>('/agents/:id/preview', async (req) => { const a = repos.getAgent(app.db, req.params.id) if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) const policy = evaluateAgentPolicy(app.db, a.id) + const limitRaw = Number(req.query.limit_cidrs ?? '50') + const limit = Number.isFinite(limitRaw) + ? Math.min(Math.max(0, Math.floor(limitRaw)), 5000) + : 50 return { - ...policy, - deny_cidrs: policy.denyCidrs, - allow_cidrs: policy.allowCidrs, - policy_mode: policy.policyMode, + default_action: policy.defaultAction, + hash: policy.hash, + generation: policy.generation, sync_interval_sec: policy.syncIntervalSec, + apply_version: policy.applyVersion, + summary: { + sets: policy.summary.sets, + rules_deny: policy.summary.rulesDeny, + rules_allow: policy.summary.rulesAllow, + cidrs_deny: policy.summary.cidrsDeny, + cidrs_allow: policy.summary.cidrsAllow, + overrides: policy.summary.overrides, + conflicts_dropped: policy.summary.conflictsDropped, + }, + chain: policy.chain.map((s) => ({ + set_id: s.setId, + set_name: s.setName, + rule_id: s.ruleId, + action: s.action, + source_kind: s.sourceKind, + source_label: s.sourceLabel, + cidr_count: s.cidrCount, + })), + deny_cidrs: truncateCidrs(policy.denyCidrs, limit), + allow_cidrs: truncateCidrs(policy.allowCidrs, limit), + deny_cidrs_total: policy.denyCidrs.length, + allow_cidrs_total: policy.allowCidrs.length, } }) @@ -269,14 +299,15 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( const body = patchAgentBodySchema.parse(req.body) const a = repos.getAgent(app.db, req.params.id) if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) - const updated = repos.updateAgent(app.db, a.id, { + const nextDefault = body.default_action + const updated = repos.updateAgent(app.db, a.id, { name: body.name, - policyMode: body.policy_mode, + defaultAction: nextDefault, settingsJson: body.settings ? JSON.stringify(body.settings) : undefined, policyGeneration: - body.policy_mode && body.policy_mode !== a.policyMode + nextDefault && nextDefault !== a.defaultAction ? a.policyGeneration + 1 : a.policyGeneration, }) @@ -287,7 +318,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( summary: `Обновлён агент: ${updated!.name}`, details: { agent_id: a.id, - policy_mode: body.policy_mode, + default_action: nextDefault, name: body.name, }, }) @@ -624,7 +655,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', + policyMode: 'blacklist', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) @@ -633,7 +664,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( targetType: 'app_resource', targetId: row!.id, summary: `Создан набор политик: ${row!.name}`, - details: { set_id: row!.id, policy_mode: row!.policyMode }, + details: { set_id: row!.id }, }) return mapPolicySet(row!, app.db) }) @@ -646,37 +677,10 @@ 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 || body.policy_mode !== undefined) { + if (body.enabled !== 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 - } - } - } auditMutation(app, config, req, { action: 'policy_set.update', targetType: 'app_resource', @@ -685,7 +689,6 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( details: { set_id: s.id, enabled: body.enabled, - policy_mode: body.policy_mode, name: body.name, }, }) @@ -765,8 +768,6 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( name: s.name, description: s.description, enabled: s.enabled === 1, - policy_mode: - s.policyMode === 'whitelist' ? 'whitelist' : 'blacklist', })), } }, @@ -784,8 +785,6 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( name: s.name, description: s.description, enabled: s.enabled === 1, - policy_mode: - s.policyMode === 'whitelist' ? 'whitelist' : 'blacklist', })), } }, @@ -981,6 +980,49 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( })), })) + /** Proxy EvoBGP communities for UI autocomplete. */ + app.get('/integrations/evobgp/communities', async () => { + const apiUrl = repos.getSetting(app.db, 'evobgp_api_url') + const token = repos.getSetting(app.db, 'evobgp_api_token') + if (!apiUrl || !token) { + throw new AppError( + 'VALIDATION_ERROR', + 'Настройте evobgp_api_url и evobgp_api_token', + 400, + ) + } + const base = apiUrl.replace(/\/$/, '') + const res = await fetch(`${base}/v1/communities?limit=200`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(20_000), + }) + if (!res.ok) { + throw new AppError( + 'UPSTREAM_ERROR', + `EvoBGP communities HTTP ${res.status}`, + 502, + ) + } + const data = (await res.json()) as { + items?: { + id?: string + community?: string + title?: string | null + }[] + } + const items = (data.items ?? []) + .filter((x) => x.id && x.community) + .map((x) => ({ + id: x.id!, + community: x.community!, + title: x.title ?? null, + })) + return { items } + }) + // Settings app.get('/settings', async () => { const rows = repos.listSettings(app.db) diff --git a/apps/api/src/services/install-links.test.ts b/apps/api/src/services/install-links.test.ts index 2d17cfb..5071423 100644 --- a/apps/api/src/services/install-links.test.ts +++ b/apps/api/src/services/install-links.test.ts @@ -186,12 +186,16 @@ describe('install-links', () => { const body = policy.json() as { deny_cidrs: string[] allow_cidrs: string[] + default_action: string policy_mode: string + apply_version: number hash: string } expect(body.deny_cidrs).toEqual([]) expect(body.allow_cidrs).toEqual([]) + expect(body.default_action).toBe('accept') expect(body.policy_mode).toBe('blacklist') + expect(body.apply_version).toBe(2) expect(body.hash).toMatch(/^sha256:/) const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' }) diff --git a/apps/api/src/services/lists/refresh.ts b/apps/api/src/services/lists/refresh.ts index 92af454..2c38e28 100644 --- a/apps/api/src/services/lists/refresh.ts +++ b/apps/api/src/services/lists/refresh.ts @@ -57,8 +57,7 @@ async function fetchEvobgpCommunity( communityId: string, ): Promise { const base = apiUrl.replace(/\/$/, '') - // Prefer published revision prefixes filtered by community when available. - const url = `${base}/v1/directories/communities/${encodeURIComponent(communityId)}/prefixes` + const url = `${base}/v1/communities/${encodeURIComponent(communityId)}/prefixes?limit=5000` const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, @@ -66,27 +65,20 @@ async function fetchEvobgpCommunity( }, signal: AbortSignal.timeout(45_000), }) - if (res.ok) { - const data = (await res.json()) as { items?: { prefix?: string }[]; prefixes?: string[] } - if (Array.isArray(data.prefixes)) return uniq(data.prefixes) - if (Array.isArray(data.items)) { - return uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean)) - } + if (!res.ok) { + throw new Error(`EvoBGP community prefixes HTTP ${res.status}`) } - // Fallback: modules lookup / openapi-compatible list - const alt = `${base}/v1/lookup?q=${encodeURIComponent(communityId)}` - const res2 = await fetch(alt, { - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - }, - signal: AbortSignal.timeout(45_000), - }) - if (!res2.ok) { - throw new Error(`EvoBGP community fetch failed: ${res.status}/${res2.status}`) + const data = (await res.json()) as { + items?: { prefix?: string }[] + prefixes?: string[] } - const data2 = (await res2.json()) as { prefixes?: string[] } - return uniq(data2.prefixes ?? []) + if (Array.isArray(data.prefixes) && data.prefixes.length > 0) { + return uniq(data.prefixes) + } + if (Array.isArray(data.items)) { + return uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean)) + } + return [] } export async function refreshIpList(db: Db, listId: string): Promise { diff --git a/apps/api/src/services/policy/evaluate.ts b/apps/api/src/services/policy/evaluate.ts index a0e4f5a..0716027 100644 --- a/apps/api/src/services/policy/evaluate.ts +++ b/apps/api/src/services/policy/evaluate.ts @@ -1,14 +1,45 @@ import { createHash } from 'node:crypto' import type { Db } from '@evofw/db' import { repos } from '@evofw/db' +import { + defaultActionFromLegacyMode, + legacyModeFromDefaultAction, + type DefaultAction, +} from '@evofw/shared' + +export const POLICY_APPLY_VERSION = 2 as const + +export type PolicyChainStep = { + setId: string | null + setName: string | null + ruleId: string | null + action: 'allow' | 'deny' + sourceKind: 'list' | 'cidr' | 'hostname' | 'override' + sourceLabel: string + cidrCount: number +} export type EvaluatedPolicy = { generation: number hash: string + applyVersion: typeof POLICY_APPLY_VERSION + defaultAction: DefaultAction + /** @deprecated mirror for old agents */ policyMode: 'blacklist' | 'whitelist' denyCidrs: string[] allowCidrs: string[] + conflictsDropped: number syncIntervalSec: number + chain: PolicyChainStep[] + summary: { + sets: number + rulesDeny: number + rulesAllow: number + cidrsDeny: number + cidrsAllow: number + overrides: number + conflictsDropped: number + } } function uniq(cidrs: string[]): string[] { @@ -44,17 +75,25 @@ 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' +function resolveDefaultAction(agentDefaultAction: string | null | undefined): DefaultAction { + if (agentDefaultAction === 'drop' || agentDefaultAction === 'accept') { + return agentDefaultAction + } + return defaultActionFromLegacyMode(agentDefaultAction) +} + +function sourceMeta(rule: { + cidr: string | null + listId: string | null + hostname: string | null +}): { kind: 'list' | 'cidr' | 'hostname'; label: string } { + if (rule.cidr?.trim()) { + return { kind: 'cidr', label: rule.cidr.trim() } + } + if (rule.hostname?.trim()) { + return { kind: 'hostname', label: rule.hostname.trim() } + } + return { kind: 'list', label: rule.listId ?? 'list' } } /** Evaluate allow/deny sets for an agent from assigned policy sets. */ @@ -64,34 +103,69 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy { throw new Error(`agent not found: ${agentId}`) } + const assignedSets = repos + .listSetsForAgent(db, agentId) + .filter((s) => s.enabled === 1) const ordered = repos.listPolicyRulesForAgent(db, agentId) + const overrides = repos.listOverrides(db, agentId) const deny: string[] = [] const allow: string[] = [] + const chain: PolicyChainStep[] = [] + let rulesDeny = 0 + let rulesAllow = 0 for (const rule of ordered) { const cidrs = expandRule(db, rule) - if (rule.action === 'deny') deny.push(...cidrs) - else allow.push(...cidrs) + const action = rule.action === 'deny' ? 'deny' : 'allow' + if (action === 'deny') { + deny.push(...cidrs) + rulesDeny += 1 + } else { + allow.push(...cidrs) + rulesAllow += 1 + } + const src = sourceMeta(rule) + const setName = + assignedSets.find((s) => s.setId === rule.setId)?.name ?? null + chain.push({ + setId: rule.setId, + setName, + ruleId: rule.id, + action, + sourceKind: src.kind, + sourceLabel: src.label, + cidrCount: cidrs.length, + }) } - for (const o of repos.listOverrides(db, agentId)) { - if (o.action === 'deny') deny.push(o.cidr) + for (const o of overrides) { + const action = o.action === 'deny' ? 'deny' : 'allow' + if (action === 'deny') deny.push(o.cidr) else allow.push(o.cidr) + chain.push({ + setId: null, + setName: null, + ruleId: null, + action, + sourceKind: 'override', + sourceLabel: o.cidr, + cidrCount: 1, + }) } const denyCidrs = uniq(deny) - const allowCidrs = uniq(allow) - 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 denySet = new Set(denyCidrs) + const allowRaw = uniq(allow) + const allowCidrs = allowRaw.filter((c) => !denySet.has(c)) + const conflictsDropped = allowRaw.length - allowCidrs.length + const defaultAction = resolveDefaultAction(agent.defaultAction) + const policyMode = legacyModeFromDefaultAction(defaultAction) const payload = JSON.stringify({ + apply_version: POLICY_APPLY_VERSION, generation: agent.policyGeneration, - policyMode, + defaultAction, denyCidrs, allowCidrs, }) @@ -103,9 +177,27 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy { return { generation: agent.policyGeneration, hash, + applyVersion: POLICY_APPLY_VERSION, + defaultAction, policyMode, denyCidrs, allowCidrs, + conflictsDropped, syncIntervalSec, + chain, + summary: { + sets: assignedSets.length, + rulesDeny, + rulesAllow, + cidrsDeny: denyCidrs.length, + cidrsAllow: allowCidrs.length, + overrides: overrides.length, + conflictsDropped, + }, } } + +export function truncateCidrs(cidrs: string[], limit: number): string[] { + if (limit <= 0) return [] + return cidrs.slice(0, limit) +} diff --git a/apps/api/src/services/policy/mikrotik-rsc.test.ts b/apps/api/src/services/policy/mikrotik-rsc.test.ts index 4b1ba66..fbc2d4d 100644 --- a/apps/api/src/services/policy/mikrotik-rsc.test.ts +++ b/apps/api/src/services/policy/mikrotik-rsc.test.ts @@ -1,64 +1,60 @@ -import { describe, it, expect } from 'vitest' -import { renderMikrotikPolicyRsc, isIpv4Cidr } from './mikrotik-rsc.js' -import type { EvaluatedPolicy } from './evaluate.js' +import { describe, expect, it } from 'vitest' +import { + POLICY_APPLY_VERSION, + type EvaluatedPolicy, +} from './evaluate.js' +import { renderMikrotikPolicyRsc } from './mikrotik-rsc.js' function basePolicy( - overrides: Partial = {}, + patch: Partial = {}, ): EvaluatedPolicy { return { generation: 3, hash: 'sha256:abc', + applyVersion: POLICY_APPLY_VERSION, + defaultAction: 'accept', policyMode: 'blacklist', denyCidrs: ['1.2.3.0/24', '2001:db8::/32', '10.0.0.1/32'], allowCidrs: ['8.8.8.8/32', 'fe80::1/128'], + conflictsDropped: 0, syncIntervalSec: 60, - ...overrides, + chain: [], + summary: { + sets: 1, + rulesDeny: 1, + rulesAllow: 1, + cidrsDeny: 2, + cidrsAllow: 1, + overrides: 0, + conflictsDropped: 0, + }, + ...patch, } } -describe('mikrotik-rsc', () => { - it('isIpv4Cidr skips IPv6', () => { - expect(isIpv4Cidr('1.2.3.0/24')).toBe(true) - expect(isIpv4Cidr('2001:db8::/32')).toBe(false) - }) - - it('renders blacklist: lists + BL enabled / WL disabled', () => { +describe('renderMikrotikPolicyRsc', () => { + it('renders accept default: lists + default-drop disabled', () => { const rsc = renderMikrotikPolicyRsc(basePolicy()) - expect(rsc).toContain('# evofw hash=sha256:abc mode=blacklist gen=3') expect(rsc).toContain( - '/ip firewall address-list remove [find list=EVOFW_DENY]', - ) - expect(rsc).toContain( - '/ip firewall address-list remove [find list=EVOFW_ALLOW]', - ) - expect(rsc).toContain( - 'add list=EVOFW_DENY address=1.2.3.0/24 comment=evofw', - ) - expect(rsc).toContain( - 'add list=EVOFW_DENY address=10.0.0.1/32 comment=evofw', + '# evofw hash=sha256:abc default_action=accept apply_version=2 gen=3', ) + expect(rsc).toContain('list=EVOFW_DENY') + expect(rsc).toContain('list=EVOFW_ALLOW') + expect(rsc).toContain('address=1.2.3.0/24') expect(rsc).not.toContain('2001:db8') expect(rsc).toContain( - 'add list=EVOFW_ALLOW address=8.8.8.8/32 comment=evofw', - ) - expect(rsc).toContain( - 'set [find comment=evofw-bl-drop-input] disabled=no', - ) - expect(rsc).toContain( - 'set [find comment=evofw-wl-accept-forward] disabled=yes', + 'evofw-default-drop-forward] disabled=yes', ) + expect(rsc).toContain('evofw-deny-drop-input] disabled=no') }) - it('renders whitelist: WL enabled / BL disabled', () => { + it('renders drop default: default-drop enabled', () => { const rsc = renderMikrotikPolicyRsc( - basePolicy({ policyMode: 'whitelist' }), + basePolicy({ defaultAction: 'drop', policyMode: 'whitelist' }), ) - expect(rsc).toContain('mode=whitelist') + expect(rsc).toContain('default_action=drop') expect(rsc).toContain( - 'set [find comment=evofw-bl-drop-forward] disabled=yes', - ) - expect(rsc).toContain( - 'set [find comment=evofw-wl-drop-forward] disabled=no', + 'evofw-default-drop-forward] disabled=no', ) }) }) diff --git a/apps/api/src/services/policy/mikrotik-rsc.ts b/apps/api/src/services/policy/mikrotik-rsc.ts index 1b31b87..9e839a2 100644 --- a/apps/api/src/services/policy/mikrotik-rsc.ts +++ b/apps/api/src/services/policy/mikrotik-rsc.ts @@ -7,23 +7,25 @@ export function isIpv4Cidr(cidr: string): boolean { } function escAddress(cidr: string): string { - // CIDRs are alphanumeric + . / - ; quote if anything odd const t = cidr.trim() if (/^[0-9./-]+$/.test(t)) return t return `"${t.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` } /** - * RouterOS 7.x script: rebuild EVOFW_* address-lists and toggle filter mode. - * Device: /tool fetch → /import (no JSON parse on router). + * RouterOS 7.x script: rebuild EVOFW_* address-lists. + * Unified chain: deny drop → allow accept → default (accept|drop). + * Expects permanent filter rules with comments: + * evofw-deny-drop-input / evofw-deny-drop-forward (always on) + * evofw-allow-accept-forward (always on for forward path) + * evofw-default-drop-forward (enabled when default_action=drop) */ export function renderMikrotikPolicyRsc(policy: EvaluatedPolicy): string { - const isBl = policy.policyMode === 'blacklist' - const blDisabled = isBl ? 'no' : 'yes' - const wlDisabled = isBl ? 'yes' : 'no' + const defaultDrop = policy.defaultAction === 'drop' + const defaultDropDisabled = defaultDrop ? 'no' : 'yes' const lines: string[] = [ - `# evofw hash=${policy.hash} mode=${policy.policyMode} gen=${policy.generation}`, + `# evofw hash=${policy.hash} default_action=${policy.defaultAction} apply_version=${policy.applyVersion} gen=${policy.generation}`, '/ip firewall address-list remove [find list=EVOFW_DENY]', '/ip firewall address-list remove [find list=EVOFW_ALLOW]', ] @@ -42,10 +44,15 @@ export function renderMikrotikPolicyRsc(policy: EvaluatedPolicy): string { } lines.push( - `:do { /ip firewall filter set [find comment=evofw-bl-drop-input] disabled=${blDisabled} } on-error={}`, - `:do { /ip firewall filter set [find comment=evofw-bl-drop-forward] disabled=${blDisabled} } on-error={}`, - `:do { /ip firewall filter set [find comment=evofw-wl-accept-forward] disabled=${wlDisabled} } on-error={}`, - `:do { /ip firewall filter set [find comment=evofw-wl-drop-forward] disabled=${wlDisabled} } on-error={}`, + `:do { /ip firewall filter set [find comment=evofw-deny-drop-input] disabled=no } on-error={}`, + `:do { /ip firewall filter set [find comment=evofw-deny-drop-forward] disabled=no } on-error={}`, + `:do { /ip firewall filter set [find comment=evofw-allow-accept-forward] disabled=no } on-error={}`, + `:do { /ip firewall filter set [find comment=evofw-default-drop-forward] disabled=${defaultDropDisabled} } on-error={}`, + // Legacy comments from bl/wl toggle era — keep disabled + `:do { /ip firewall filter set [find comment=evofw-bl-drop-input] disabled=yes } on-error={}`, + `:do { /ip firewall filter set [find comment=evofw-bl-drop-forward] disabled=yes } on-error={}`, + `:do { /ip firewall filter set [find comment=evofw-wl-accept-forward] disabled=yes } on-error={}`, + `:do { /ip firewall filter set [find comment=evofw-wl-drop-forward] disabled=yes } on-error={}`, ) return `${lines.join('\n')}\n` diff --git a/apps/api/src/services/policy/policy-mode.test.ts b/apps/api/src/services/policy/policy-mode.test.ts index d7faeb1..60e91ee 100644 --- a/apps/api/src/services/policy/policy-mode.test.ts +++ b/apps/api/src/services/policy/policy-mode.test.ts @@ -16,7 +16,25 @@ const testConfig: AppConfig = { enrollSeed: 'test-seed', } -describe('policy set mode + rules', () => { +async function createAgent( + app: Awaited>, + name: string, +) { + const link = await app.inject({ + method: 'POST', + url: '/api/v1/install-links', + payload: { name, platform: 'linux' }, + }) + expect(link.statusCode).toBe(201) + const agentId = (link.json() as { agent_id: string }).agent_id + await app.inject({ + method: 'POST', + url: `/api/v1/agents/${agentId}/approve`, + }) + return agentId +} + +describe('classic policy default_action', () => { const appPromise = buildApp({ memory: true, config: testConfig }) afterAll(async () => { @@ -24,104 +42,99 @@ describe('policy set mode + rules', () => { await app.close() }) - it('set policy_mode and reorder; disabled rules skipped in policy', async () => { + it('mixed deny/allow; deny wins exact; preview has default_action', 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', - }, + payload: { name: 'mixed-set' }, }) expect(created.statusCode).toBe(200) - const set = created.json() as { id: string; policy_mode: string } - expect(set.policy_mode).toBe('whitelist') + const setId = (created.json() as { id: string }).id - 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 } + for (const payload of [ + { set_id: setId, action: 'deny', cidr: '10.0.0.1/32' }, + { set_id: setId, action: 'allow', cidr: '10.0.0.1/32' }, + { set_id: setId, action: 'allow', cidr: '10.0.0.2/32' }, + ]) { + const r = await app.inject({ + method: 'POST', + url: '/api/v1/rules', + payload, + }) + expect(r.statusCode).toBe(200) + } - 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 agentId = await createAgent(app, 'pol-agent') - 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] }, + url: `/api/v1/agents/${agentId}/policy-sets`, + payload: { set_ids: [setId] }, }) expect(assign.statusCode).toBe(200) - const policy = await app.inject({ - method: 'GET', - url: '/v1/agent/policy', - headers: { - authorization: 'Bearer evofw_policy_mode_token_abcdef12', - }, + const patch = await app.inject({ + method: 'PATCH', + url: `/api/v1/agents/${agentId}`, + payload: { default_action: 'drop' }, }) - expect(policy.statusCode).toBe(200) - const body = policy.json() as { - policy_mode: string + expect(patch.statusCode).toBe(200) + expect((patch.json() as { default_action: string }).default_action).toBe( + 'drop', + ) + + const preview = await app.inject({ + method: 'GET', + url: `/api/v1/agents/${agentId}/preview`, + }) + expect(preview.statusCode).toBe(200) + const body = preview.json() as { + default_action: string + apply_version: number + deny_cidrs: string[] allow_cidrs: string[] + summary: { conflicts_dropped: number } + chain: unknown[] } - expect(body.policy_mode).toBe('whitelist') - expect(body.allow_cidrs).toContain('10.0.0.2/32') + expect(body.default_action).toBe('drop') + expect(body.apply_version).toBe(2) + expect(body.deny_cidrs).toContain('10.0.0.1/32') expect(body.allow_cidrs).not.toContain('10.0.0.1/32') - expect(rule1.enabled).toBe(true) + expect(body.allow_cidrs).toContain('10.0.0.2/32') + expect(body.summary.conflicts_dropped).toBeGreaterThanOrEqual(1) + expect(body.chain.length).toBeGreaterThanOrEqual(3) + }) + + it('allows assigning sets without same-mode lock', async () => { + const app = await appPromise + await app.ready() + + const a = await app.inject({ + method: 'POST', + url: '/api/v1/policy-sets', + payload: { name: 'set-a', policy_mode: 'blacklist' }, + }) + const b = await app.inject({ + method: 'POST', + url: '/api/v1/policy-sets', + payload: { name: 'set-b', policy_mode: 'whitelist' }, + }) + expect(a.statusCode).toBe(200) + expect(b.statusCode).toBe(200) + const setA = (a.json() as { id: string }).id + const setB = (b.json() as { id: string }).id + + const agentId = await createAgent(app, 'multi-mode-agent') + + const assign = await app.inject({ + method: 'PUT', + url: `/api/v1/agents/${agentId}/policy-sets`, + payload: { set_ids: [setA, setB] }, + }) + expect(assign.statusCode).toBe(200) + expect((assign.json() as { items: unknown[] }).items).toHaveLength(2) }) }) diff --git a/apps/web/src/components/agents/agent-effective-cidrs.tsx b/apps/web/src/components/agents/agent-effective-cidrs.tsx new file mode 100644 index 0000000..96a7480 --- /dev/null +++ b/apps/web/src/components/agents/agent-effective-cidrs.tsx @@ -0,0 +1,115 @@ +import type { AgentPolicyPreview } from '@evofw/shared' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Badge } from '@/components/reui/badge' +import { EmptyState } from '@/components/empty-state' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@evofw/ui/components/tabs' +import { Skeleton } from '@evofw/ui/components/skeleton' + +/** + * Effective CIDR bags — tabs Блок / Accept. + * Preview: https://reui.io/preview/base/components/c-tabs-2 + * · https://reui.io/preview/base/data-grid-filtering-2 + */ + +type AgentEffectiveCidrsProps = { + preview?: AgentPolicyPreview + isLoading?: boolean +} + +function CidrList({ + items, + total, + emptyTitle, +}: { + items: string[] + total: number + emptyTitle: string +}) { + if (items.length === 0) { + return ( + + ) + } + return ( +
    + {items.map((c) => ( +
  • + {c} +
  • + ))} + {total > items.length ? ( +
  • + … и ещё {total - items.length} +
  • + ) : null} +
+ ) +} + +export function AgentEffectiveCidrs({ + preview, + isLoading, +}: AgentEffectiveCidrsProps) { + if (isLoading) { + return + } + + const deny = preview?.deny_cidrs ?? [] + const allow = preview?.allow_cidrs ?? [] + const denyTotal = preview?.deny_cidrs_total ?? deny.length + const allowTotal = preview?.allow_cidrs_total ?? allow.length + + return ( + + +
+ Effective CIDR + + apply v{preview?.apply_version ?? 2} + +
+ + После deny-wins · hash {preview?.hash?.slice(0, 18) ?? '—'}… + +
+ + + + Блок ({denyTotal}) + Accept ({allowTotal}) + + + + + + + + + + + ) +} diff --git a/apps/web/src/components/agents/agent-facts-panel.tsx b/apps/web/src/components/agents/agent-facts-panel.tsx new file mode 100644 index 0000000..86af10d --- /dev/null +++ b/apps/web/src/components/agents/agent-facts-panel.tsx @@ -0,0 +1,128 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import type { Agent, DefaultAction } from '@evofw/shared' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { apiFetch } from '@/lib/api' +import { Field, FieldLabel } from '@evofw/ui/components/field' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@evofw/ui/components/select' +import { Separator } from '@evofw/ui/components/separator' + +/** + * Agent facts panel — SA3 RunFacts DNA (editable default_action). + * Preview: https://reui.io/preview/base/solution-agents-3 + * · https://reui.io/preview/base/settings-3 + */ + +type AgentFactsPanelProps = { + agent: Agent +} + +function formatWhen(iso?: string | null): string { + if (!iso) return '—' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + return d.toLocaleString('ru-RU') +} + +export function AgentFactsPanel({ agent }: AgentFactsPanelProps) { + const qc = useQueryClient() + const defaultAction: DefaultAction = + agent.default_action === 'drop' ? 'drop' : 'accept' + + const patch = useMutation({ + mutationFn: (default_action: DefaultAction) => + apiFetch(`/api/v1/agents/${agent.id}`, { + method: 'PATCH', + body: JSON.stringify({ default_action }), + }), + onSuccess: () => { + toast.success('Default action обновлён') + void qc.invalidateQueries({ queryKey: ['agents'] }) + void qc.invalidateQueries({ queryKey: ['agents', agent.id, 'preview'] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + return ( + + + Параметры + Default + identity + + + + Если не совпало + +

+ Пакет вне deny/allow → {defaultAction === 'drop' ? 'DROP' : 'ACCEPT'} +

+
+ + + +
+
+
Hostname
+
{agent.hostname ?? '—'}
+
+
+
Token
+
{agent.token_prefix}
+
+
+
Client
+
{agent.client_version ?? '—'}
+
+
+
Last seen IP
+
{agent.last_seen_ip ?? '—'}
+
+
+
Created
+
{formatWhen(agent.created_at)}
+
+
+
Approved
+
+ {formatWhen(agent.approved_at)} +
+
+
+
Generation
+
{agent.policy_generation}
+
+
+
+ + ) +} diff --git a/apps/web/src/components/agents/agent-policy-sets-sortable.tsx b/apps/web/src/components/agents/agent-policy-sets-sortable.tsx index 4581af2..a97672b 100644 --- a/apps/web/src/components/agents/agent-policy-sets-sortable.tsx +++ b/apps/web/src/components/agents/agent-policy-sets-sortable.tsx @@ -8,7 +8,6 @@ import { } from 'lucide-react' import { toast } from 'sonner' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import type { PolicySet } from '@evofw/shared' import { Sortable, SortableItem, @@ -42,7 +41,6 @@ import { /** * Agent-assigned policy sets — ReUI Sortable (c-sortable-5 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 */ @@ -52,7 +50,6 @@ export type AgentPolicySetRow = { name: string description?: string | null enabled: boolean - policy_mode: 'blacklist' | 'whitelist' } type AgentPolicySetsSortableProps = { @@ -72,24 +69,10 @@ export function AgentPolicySetsSortable({ setItems(assignedQ.data?.items ?? []) }, [assignedQ.data]) - const assignedMode = items[0]?.policy_mode - const availableSets = useMemo(() => { const assigned = new Set(items.map((i) => i.set_id)) - return (catalogQ.data?.items ?? []).filter((s) => { - if (assigned.has(s.id)) return false - if (assignedMode && s.policy_mode !== assignedMode) return false - return true - }) - }, [catalogQ.data?.items, items, assignedMode]) - - const conflictSets = useMemo(() => { - if (!assignedMode) return [] as PolicySet[] - const assigned = new Set(items.map((i) => i.set_id)) - return (catalogQ.data?.items ?? []).filter( - (s) => !assigned.has(s.id) && s.policy_mode !== assignedMode, - ) - }, [catalogQ.data?.items, items, assignedMode]) + return (catalogQ.data?.items ?? []).filter((s) => !assigned.has(s.id)) + }, [catalogQ.data?.items, items]) const persist = useMutation({ mutationFn: (set_ids: string[]) => @@ -103,6 +86,7 @@ export function AgentPolicySetsSortable({ onSuccess: (res) => { setItems(res.items) void qc.invalidateQueries({ queryKey: ['agents', agentId] }) + void qc.invalidateQueries({ queryKey: ['agents', agentId, 'preview'] }) void qc.invalidateQueries({ queryKey: ['policy-sets'] }) }, onError: (e: Error) => { @@ -127,12 +111,6 @@ export function AgentPolicySetsSortable({ if (!addId) return const set = (catalogQ.data?.items ?? []).find((s) => s.id === addId) if (!set) return - if (assignedMode && set.policy_mode !== assignedMode) { - toast.error( - `Режим набора (${set.policy_mode}) не совпадает с текущим (${assignedMode})`, - ) - return - } const next: AgentPolicySetRow[] = [ ...items, { @@ -141,7 +119,6 @@ export function AgentPolicySetsSortable({ name: set.name, description: set.description, enabled: set.enabled, - policy_mode: set.policy_mode, }, ] const prev = items @@ -168,7 +145,7 @@ export function AgentPolicySetsSortable({ - Перетащите для приоритета · один режим на агента + Порядок = приоритет merge · deny → allow → default
@@ -191,9 +168,7 @@ export function AgentPolicySetsSortable({ ))} {availableSets.length === 0 ? (
- {conflictSets.length > 0 - ? 'Нет совместимых наборов' - : 'Все наборы уже назначены'} + Все наборы уже назначены
) : null} @@ -242,23 +217,13 @@ export function AgentPolicySetsSortable({ - +
{row.name} - - {row.policy_mode} - @@ -300,13 +265,6 @@ export function AgentPolicySetsSortable({ )} - - {conflictSets.length > 0 && items.length > 0 ? ( -

- {conflictSets.length} набор(ов) скрыты из‑за другого режима ( - {assignedMode}). -

- ) : null}
) } diff --git a/apps/web/src/components/agents/agent-policy-trace.tsx b/apps/web/src/components/agents/agent-policy-trace.tsx new file mode 100644 index 0000000..c02a753 --- /dev/null +++ b/apps/web/src/components/agents/agent-policy-trace.tsx @@ -0,0 +1,126 @@ +import { BanIcon, ShieldCheckIcon } from 'lucide-react' +import type { AgentPolicyPreview } from '@evofw/shared' +import { + Timeline, + TimelineContent, + TimelineHeader, + TimelineIndicator, + TimelineItem, + TimelineSeparator, + TimelineTitle, +} from '@/components/reui/timeline' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { + Alert, + AlertDescription, + AlertTitle, +} from '@/components/reui/alert' +import { Badge } from '@/components/reui/badge' +import { EmptyState } from '@/components/empty-state' +import { Skeleton } from '@evofw/ui/components/skeleton' + +/** + * Policy chain trace — SA3 Timeline DNA. + * Preview: https://reui.io/preview/base/solution-agents-3 + * · https://reui.io/preview/base/components/c-timeline-6 + * Docs: https://reui.io/docs/components/base/timeline + */ + +type AgentPolicyTraceProps = { + preview?: AgentPolicyPreview + isLoading?: boolean +} + +export function AgentPolicyTrace({ + preview, + isLoading, +}: AgentPolicyTraceProps) { + if (isLoading) { + return + } + + const chain = preview?.chain ?? [] + const conflicts = preview?.summary.conflicts_dropped ?? 0 + + return ( + + + Цепочка политики + + deny → allow → default ( + {preview?.default_action === 'drop' ? 'Drop' : 'Accept'}) + + + + {conflicts > 0 ? ( + + + Конфликты + + {conflicts} CIDR исключены из allow (deny wins, exact match) + + + ) : null} + + {chain.length === 0 ? ( + + ) : ( + + {chain.map((step, i) => { + const isDeny = step.action === 'deny' + return ( + + + + + + {isDeny ? ( + + ) : ( + + )} + + {isDeny ? 'Блок' : 'Accept'} + + {step.source_label} + + + + {[ + step.set_name, + step.source_kind, + `${step.cidr_count} CIDR`, + ] + .filter(Boolean) + .join(' · ')} + + + ) + })} + + )} + + {preview ? ( +

+ Блок: {preview.deny_cidrs_total} · Accept:{' '} + {preview.allow_cidrs_total} · gen {preview.generation} +

+ ) : null} +
+ + ) +} diff --git a/apps/web/src/components/reui/autocomplete.tsx b/apps/web/src/components/reui/autocomplete.tsx new file mode 100644 index 0000000..b580d08 --- /dev/null +++ b/apps/web/src/components/reui/autocomplete.tsx @@ -0,0 +1,346 @@ +"use client" + +import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@evofw/ui/lib/utils" +import { ScrollArea } from "@evofw/ui/components/scroll-area" +import { XIcon, ChevronsUpDownIcon } from "lucide-react" + +const inputVariants = cva( + "outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3", + { + variants: { + size: { + sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5", + default: + "h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75", + lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2", + }, + }, + defaultVariants: { + size: "default", + }, + } +) + +const Autocomplete = AutocompletePrimitive.Root + +function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) { + return ( + + ) +} + +function AutocompleteInput({ + className, + size = "default", + showClear = false, + showTrigger = false, + ...props +}: Omit & + VariantProps & { + showClear?: boolean + showTrigger?: boolean + }) { + return ( +
+ + {showTrigger && } + {showClear && } +
+ ) +} + +function AutocompleteStatus({ + className, + ...props +}: AutocompletePrimitive.Status.Props) { + return ( + + ) +} + +function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) { + return ( + + ) +} + +function AutocompleteBackdrop({ + ...props +}: AutocompletePrimitive.Backdrop.Props) { + return ( + + ) +} + +function AutocompletePositioner({ + className, + ...props +}: AutocompletePrimitive.Positioner.Props) { + return ( + + ) +} + +function AutocompleteList({ + className, + scrollAreaClassName, + ...props +}: AutocompletePrimitive.List.Props & { + scrollAreaClassName?: string + scrollFade?: boolean + scrollbarGutter?: boolean +}) { + return ( + + + + ) +} + +function AutocompleteCollection({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteRow({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export interface AutocompleteContentProps extends React.ComponentProps< + typeof AutocompletePrimitive.Popup +> { + align?: AutocompletePrimitive.Positioner.Props["align"] + sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"] + alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"] + side?: AutocompletePrimitive.Positioner.Props["side"] + anchor?: AutocompletePrimitive.Positioner.Props["anchor"] + showBackdrop?: boolean +} + +function AutocompleteContent({ + className, + children, + showBackdrop = false, + align = "start", + sideOffset = 4, + alignOffset = 0, + side = "bottom", + anchor, + ...props +}: AutocompleteContentProps) { + return ( + + {showBackdrop && } + +
+ + {children} + +
+
+
+ ) +} + +function AutocompleteGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteGroupLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteEmpty({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteClear({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function AutocompleteTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function AutocompleteArrow({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AutocompleteSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Autocomplete, + AutocompleteValue, + AutocompleteTrigger, + AutocompleteInput, + AutocompleteStatus, + AutocompletePortal, + AutocompleteBackdrop, + AutocompletePositioner, + AutocompleteContent, + AutocompleteList, + AutocompleteCollection, + AutocompleteRow, + AutocompleteItem, + AutocompleteGroup, + AutocompleteGroupLabel, + AutocompleteEmpty, + AutocompleteClear, + AutocompleteArrow, + AutocompleteSeparator, +} \ No newline at end of file diff --git a/apps/web/src/components/rules/policy-mode-toggle.tsx b/apps/web/src/components/rules/policy-mode-toggle.tsx deleted file mode 100644 index df25160..0000000 --- a/apps/web/src/components/rules/policy-mode-toggle.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { cn } from '@evofw/ui/lib/utils' -import { - ToggleGroup, - ToggleGroupItem, -} from '@evofw/ui/components/toggle-group' -import { - Frame, - FrameDescription, - FrameHeader, - FramePanel, - FrameTitle, -} from '@/components/reui/frame' - -type PolicyMode = 'blacklist' | 'whitelist' - -type PolicyModeToggleProps = { - value: PolicyMode - onChange: (mode: PolicyMode) => void - disabled?: boolean - className?: string -} - -/** - * Filter mode — settings-3 ToggleGroup pattern. - * Preview: https://reui.io/preview/base/settings-3 - * Docs: https://ui.shadcn.com/docs/components/base/toggle-group - */ -export function PolicyModeToggle({ - value, - onChange, - disabled, - className, -}: PolicyModeToggleProps) { - return ( - - - Режим фильтра - - Чёрный список: блокировать deny. Белый список: пропускать только - allow, остальное (forward) — DROP. - - - - { - const mode = next[0] - if (mode === 'blacklist' || mode === 'whitelist') { - onChange(mode) - } - }} - variant="outline" - size="sm" - disabled={disabled} - aria-label="Режим фильтра" - className="flex flex-wrap justify-start gap-1" - > - - Чёрный список - - - Белый список - - - - - ) -} diff --git a/apps/web/src/components/rules/policy-rules-sortable.tsx b/apps/web/src/components/rules/policy-rules-sortable.tsx index 4949f07..e04dde8 100644 --- a/apps/web/src/components/rules/policy-rules-sortable.tsx +++ b/apps/web/src/components/rules/policy-rules-sortable.tsx @@ -58,7 +58,6 @@ function ruleSubtitle(r: PolicyRule): string | null { type PolicyRulesSortableProps = { setId: string rules: PolicyRule[] - policyMode: 'blacklist' | 'whitelist' onDelete: (id: string) => void onAdd?: () => void } @@ -66,7 +65,6 @@ type PolicyRulesSortableProps = { export function PolicyRulesSortable({ setId, rules: rulesProp, - policyMode, onDelete, onAdd, }: PolicyRulesSortableProps) { @@ -105,22 +103,16 @@ export function PolicyRulesSortable({ onError: (e: Error) => toast.error(e.message), }) - const isWl = policyMode === 'whitelist' - return (
- - {isWl ? 'DROP' : 'ACCEPT'} + + deny → allow

- {isWl - ? 'По умолчанию DROP — ниже только allow-правила пропускают трафик' - : 'По умолчанию ACCEPT — ниже deny-правила блокируют адреса'} + Правила с action deny блокируют, allow — пропускают; default задаётся + на агенте

diff --git a/apps/web/src/components/rules/policy-set-icon.tsx b/apps/web/src/components/rules/policy-set-icon.tsx index fbf49df..8efa4e8 100644 --- a/apps/web/src/components/rules/policy-set-icon.tsx +++ b/apps/web/src/components/rules/policy-set-icon.tsx @@ -3,7 +3,6 @@ import { cn } from '@evofw/ui/lib/utils' import { Item, ItemMedia } from '@evofw/ui/components/item' type PolicySetIconProps = { - mode?: 'blacklist' | 'whitelist' | string | null className?: string } @@ -11,16 +10,14 @@ type PolicySetIconProps = { * KPI-style tile for policy set rows. * Preview DNA: https://reui.io/preview/base/stats-12 */ -export function PolicySetIcon({ mode, className }: PolicySetIconProps) { - const isWhitelist = mode === 'whitelist' +export function PolicySetIcon({ className }: PolicySetIconProps) { return ( diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index ac470a9..8214ca1 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -93,11 +93,30 @@ export const agentPolicySetsQueryOptions = (agentId: string) => name: string description?: string | null enabled: boolean - policy_mode: 'blacklist' | 'whitelist' }[] }>(`/api/v1/agents/${agentId}/policy-sets`), }) +export const agentPreviewQueryOptions = (agentId: string) => + queryOptions({ + queryKey: ['agents', agentId, 'preview'], + queryFn: () => + apiFetch( + `/api/v1/agents/${agentId}/preview?limit_cidrs=100`, + ), + }) + +export const evobgpCommunitiesQueryOptions = () => + queryOptions({ + queryKey: ['integrations', 'evobgp', 'communities'], + queryFn: () => + apiFetch<{ + items: import('@evofw/shared').EvobgpCommunity[] + }>('/api/v1/integrations/evobgp/communities'), + staleTime: 30_000, + retry: false, + }) + export const agentOverridesQueryOptions = (agentId: string) => queryOptions({ queryKey: ['agents', agentId, 'overrides'], diff --git a/apps/web/src/routes/_auth/agents/$id.tsx b/apps/web/src/routes/_auth/agents/$id.tsx index 1260412..5e88c8d 100644 --- a/apps/web/src/routes/_auth/agents/$id.tsx +++ b/apps/web/src/routes/_auth/agents/$id.tsx @@ -1,31 +1,20 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { useMemo, useRef, useState } from 'react' +import { useRef, useState } from 'react' import { BanIcon, CheckCircle2Icon, CircleAlertIcon, ClockIcon, Copy, - CpuIcon, CopyPlusIcon, - ShieldOffIcon, + CpuIcon, + MoreHorizontalIcon, ShieldPlusIcon, TerminalIcon, } from 'lucide-react' -import { - DetailPanel, - PageShell, - QuickActionGrid, -} from '@/components/reui-kit' -import { - Frame, - FrameDescription, - FrameHeader, - FramePanel, - FrameTitle, -} from '@/components/reui/frame' +import { DetailPanel, PageShell } from '@/components/reui-kit' import { Alert, AlertDescription, @@ -36,21 +25,34 @@ import { AgentPlatformIcon, platformLabel, } from '@/components/agents/agent-platform-icon' -import { AgentLifecycleTimeline } from '@/components/agents/agent-lifecycle-timeline' import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable' +import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace' +import { AgentFactsPanel } from '@/components/agents/agent-facts-panel' +import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs' import { AgentCloneSetsSheet, AgentOverrideSheet, } from '@/components/agents/agent-settings-sheets' -import { agentQueryOptions } from '@/queries' +import { + agentPreviewQueryOptions, + agentQueryOptions, +} from '@/queries' import { apiFetch } from '@/lib/api' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { Button } from '@evofw/ui/components/button' import { Skeleton } from '@evofw/ui/components/skeleton' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@evofw/ui/components/dropdown-menu' /** - * Agent detail — Solutions Agents DNA. - * Preview: https://reui.io/preview/base/solution-agents-3 · stats-12 · sheet-8 · c-sortable-5 + * Agent detail — SA3 layout DNA (Header + Trace 2/3 + Facts 1/3). + * Preview: https://reui.io/preview/base/solution-agents-3 + * · https://reui.io/preview/base/stats-12 + * Docs: https://reui.io/blocks/solutions/agents */ export const Route = createFileRoute('/_auth/agents/$id')({ @@ -58,6 +60,7 @@ export const Route = createFileRoute('/_auth/agents/$id')({ const agent = await queryClient.ensureQueryData( agentQueryOptions(params.id), ) + void queryClient.ensureQueryData(agentPreviewQueryOptions(params.id)) return { breadcrumb: agent.name } }, component: AgentDetailPage, @@ -68,6 +71,7 @@ function AgentDetailPage() { const qc = useQueryClient() const { copyToClipboard } = useCopyToClipboard() const agentQ = useQuery(agentQueryOptions(id)) + const previewQ = useQuery(agentPreviewQueryOptions(id)) const installRef = useRef(null) const [overrideOpen, setOverrideOpen] = useState(false) const [cloneOpen, setCloneOpen] = useState(false) @@ -94,68 +98,6 @@ function AgentDetailPage() { const a = agentQ.data - const quickActions = useMemo(() => { - if (!a) return [] - const actions = [ - { - id: 'override', - title: 'IP override', - description: 'Allow/deny поверх политики', - icon: , - iconClassName: 'text-warning [&_svg]:text-current', - badgeLabel: 'Открыть', - onSelect: () => setOverrideOpen(true), - }, - { - id: 'clone', - title: 'Копировать наборы', - description: 'С другого агента + overrides', - icon: , - iconClassName: 'text-info [&_svg]:text-current', - badgeLabel: 'Открыть', - onSelect: () => setCloneOpen(true), - }, - { - id: 'install', - title: 'Install curl', - description: a.install_curl ? 'Скопировать one-liner' : 'Недоступен', - icon: , - iconClassName: 'text-primary [&_svg]:text-current', - badgeLabel: 'Копировать', - onSelect: () => { - if (a.install_curl) { - copyToClipboard(a.install_curl) - toast.success('Скопировано') - } - installRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, - }, - ] - if (a.status === 'pending') { - actions.push({ - id: 'approve', - title: 'Approve', - description: 'Выдать политику агенту', - icon: , - iconClassName: 'text-success [&_svg]:text-current', - badgeLabel: 'Выполнить', - onSelect: () => approve.mutate(), - }) - } - if (a.status === 'approved') { - actions.push({ - id: 'revoke', - title: 'Revoke', - description: 'Отозвать доступ агента', - icon: , - iconClassName: 'text-destructive [&_svg]:text-current', - badgeLabel: 'Выполнить', - onSelect: () => revoke.mutate(), - }) - } - return actions - }, [a, approve, copyToClipboard, revoke]) - if (agentQ.isLoading || !a) { return ( @@ -171,6 +113,7 @@ function AgentDetailPage() { a.hostname, platformLabel(a.platform), `gen ${a.policy_generation}`, + a.default_action === 'drop' ? 'default Drop' : 'default Accept', ] .filter(Boolean) .join(' · ') @@ -217,13 +160,44 @@ function AgentDetailPage() { Install ) : null} - + + + } + > + + + + setOverrideOpen(true)}> + + IP override + + setCloneOpen(true)}> + + Копировать наборы + + {a.install_curl ? ( + { + copyToClipboard(a.install_curl!) + toast.success('Скопировано') + installRef.current?.scrollIntoView({ + behavior: 'smooth', + }) + }} + > + + Install curl + + ) : null} + } + > + К списку + + + } /> @@ -272,78 +246,26 @@ function AgentDetailPage() { ]} /> - - -
- +
+
+
+ +
+ +
- - - Install / identity - - Copy one-liner · hostname · token - - - - {a.install_curl ? ( -
-
-                        {a.install_curl}
-                      
- -
- ) : ( -

- Install curl недоступен -

- )} -
-
- Hostname:{' '} - - {a.hostname ?? '—'} - -
-
- Last seen IP:{' '} - - {a.last_seen_ip ?? '—'} - -
-
- Client:{' '} - - {a.client_version ?? '—'} - -
-
- Token prefix:{' '} - - {a.token_prefix} - -
-
-
- -
- -
+ +
diff --git a/apps/web/src/routes/_auth/lists/index.tsx b/apps/web/src/routes/_auth/lists/index.tsx index 41831f5..1c00397 100644 --- a/apps/web/src/routes/_auth/lists/index.tsx +++ b/apps/web/src/routes/_auth/lists/index.tsx @@ -13,8 +13,16 @@ import { listTabFilter, } from '@/components/lists/lists-columns' import { ConfirmDialog } from '@/components/confirm-dialog' -import { listsQueryOptions } from '@/queries' +import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' +import { + Autocomplete, + AutocompleteContent, + AutocompleteEmpty, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, +} from '@/components/reui/autocomplete' import { Button } from '@evofw/ui/components/button' import { Field, FieldLabel } from '@evofw/ui/components/field' import { Input } from '@evofw/ui/components/input' @@ -58,8 +66,6 @@ const CREATE_SOURCE_ITEMS = [ function ListsPage() { const navigate = useNavigate() const qc = useQueryClient() - const listsQ = useQuery(listsQueryOptions()) - const [createOpen, setCreateOpen] = useState(false) const [name, setName] = useState('') const [source, setSource] = useState('static') @@ -69,6 +75,21 @@ function ListsPage() { const [activeTab, setActiveTab] = useState('all') const [deleteListId, setDeleteListId] = useState(null) + const listsQ = useQuery(listsQueryOptions()) + const communitiesQ = useQuery({ + ...evobgpCommunitiesQueryOptions(), + enabled: createOpen && source === 'evobgp_community', + }) + + const communityItems = useMemo( + () => + (communitiesQ.data?.items ?? []).map((c) => ({ + value: c.id, + label: c.title ? `${c.community} · ${c.title}` : c.community, + })), + [communitiesQ.data?.items], + ) + const create = useMutation({ mutationFn: async () => { const config: Record = {} @@ -273,12 +294,35 @@ function ListsPage() { ) : null} {source === 'evobgp_community' ? ( - Community ID - BGP community + setExtra(e.target.value)} - /> + onValueChange={setExtra} + > + + + + {communitiesQ.isLoading + ? 'Загрузка…' + : 'Нет совпадений'} + + + {(item) => ( + + {item.label} + + )} + + + ) : null}
diff --git a/apps/web/src/routes/_auth/rules/$setId.tsx b/apps/web/src/routes/_auth/rules/$setId.tsx index 7aa3635..a618618 100644 --- a/apps/web/src/routes/_auth/rules/$setId.tsx +++ b/apps/web/src/routes/_auth/rules/$setId.tsx @@ -14,7 +14,6 @@ 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 { PolicyModeToggle } from '@/components/rules/policy-mode-toggle' import { agentsQueryOptions, listsQueryOptions, @@ -103,11 +102,7 @@ function PolicySetDetailPage() { }, [assignedIds]) const patchSet = useMutation({ - mutationFn: (body: { - enabled?: boolean - name?: string - policy_mode?: 'blacklist' | 'whitelist' - }) => + mutationFn: (body: { enabled?: boolean; name?: string }) => apiFetch(`/api/v1/policy-sets/${setId}`, { method: 'PATCH', body: JSON.stringify(body), @@ -277,8 +272,6 @@ function PolicySetDetailPage() { } const set = setQ.data - const policyMode = - set.policy_mode === 'whitelist' ? 'whitelist' : 'blacklist' return ( @@ -339,19 +332,10 @@ function PolicySetDetailPage() { ]} /> - - patchSet.mutate({ policy_mode: mode })} - /> - - setDeleteRuleId(id)} onAdd={() => setRuleOpen(true)} /> @@ -359,7 +343,7 @@ function PolicySetDetailPage() { diff --git a/apps/web/src/routes/_auth/rules/index.tsx b/apps/web/src/routes/_auth/rules/index.tsx index 4f1ade0..132d348 100644 --- a/apps/web/src/routes/_auth/rules/index.tsx +++ b/apps/web/src/routes/_auth/rules/index.tsx @@ -9,7 +9,6 @@ import { PageHeader, PageShell, ResourcePage } 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 { Badge } from '@/components/reui/badge' import { ConfirmDialog } from '@/components/confirm-dialog' import { PolicySetIcon } from '@/components/rules/policy-set-icon' import { policySetsQueryOptions } from '@/queries' @@ -120,7 +119,7 @@ function PolicySetsPage() { ), cell: ({ row }) => (
- + ), }, - { - 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 4ba000d..8ba1a34 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -63,14 +63,14 @@ Install RSC: 1. Enroll (с `install_link_id` → агент Invited → Pending). 2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW`. -3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy.rsc` → `/import` (списки + режим). +3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy.rsc` → `/import` (списки + default). -**Режим фильтра** задаётся на **наборе правил** (`/rules`), не на агенте: +**Default action** задаётся на **агенте** (`default_action: accept | drop`): -- **blacklist** — по умолчанию ACCEPT; deny-CIDR блокируются -- **whitelist** — по умолчанию DROP (forward); только allow-CIDR +- **accept** — пакет вне deny/allow пропускается +- **drop** — пакет вне deny/allow отбрасывается (forward) -Все наборы, назначенные агенту, должны иметь один режим. +Цепочка всегда: deny-drop → allow-accept → default. Наборы несут только правила deny/allow, без exclusive mode. ## Force sync diff --git a/docs/architecture.md b/docs/architecture.md index 77107fa..0c79752 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,18 +17,18 @@ 1. **Enroll** — `POST /v1/agent/enroll` + `X-EvoFW-Seed` → pending agent 2. **Approve** — UI/API → status approved -3. **Policy** — `GET /v1/agent/policy` → deny/allow CIDRs + mode + hash +3. **Policy** — `GET /v1/agent/policy` → deny/allow CIDRs + `default_action` + hash (`apply_version: 2`) 4. **Apply** — agent пишет kernel rules, `POST /v1/agent/apply-report` + stats sample 5. **Lists refresh** — cron каждые 5 мин (json_url / domains / evobgp_community) ## Политика - Именованные **наборы правил** (`policy_sets`); агенту назначается **M:N** через `agent_policy_sets` -- Правило в наборе: ровно один источник — IP-список (`list_id`), CIDR или DNS-имя (`hostname` → A/AAAA, кэш в `policy_rule_resolved`) +- Правило в наборе: `action: deny | allow` + ровно один источник — IP-список (`list_id`), CIDR или DNS-имя (`hostname` → A/AAAA, кэш в `policy_rule_resolved`) - Evaluate: правила всех назначенных enabled-наборов (sort + priority) + `ip_overrides` -- `blacklist` — default accept, apply deny set -- `whitelist` — default drop, apply allow set (+ lo/established на Linux) -- Overrides, смена наборов и refresh DNS/lists бампят `policy_generation` +- Цепочка ядра **всегда**: deny → allow → `default_action` (`accept` | `drop` на агенте) +- Exact overlap: `allow \ deny` (`conflicts_dropped`); deny wins +- Overrides, смена наборов, `default_action` и refresh DNS/lists бампят `policy_generation` ## Auth diff --git a/docs/integrate-evobgp.md b/docs/integrate-evobgp.md index 8e3b838..350b6de 100644 --- a/docs/integrate-evobgp.md +++ b/docs/integrate-evobgp.md @@ -7,15 +7,22 @@ EvoFirewall использует EvoBGP как **источник префикс В UI Settings или `settings` table: - `evobgp_api_url` — base URL EvoBGP API -- `evobgp_api_token` — API key (viewer+) +- `evobgp_api_token` — API key (viewer+ / `bgp:directories:read`) -При refresh списка: +## Refresh списка -1. `GET {api}/v1/directories/communities/{id}/prefixes` (если доступен) -2. fallback `GET {api}/v1/lookup?q={community_id}` +При refresh списка `evobgp_community`: + +1. `GET {api}/v1/communities/{id}/prefixes?limit=5000` +2. Ответ: `{ items: [{ prefix }], prefixes: string[], has_more, next_cursor }` +3. Entries заменяются; generation агентов с правилами на этот list бампится + +## Autocomplete в UI + +`GET /api/v1/integrations/evobgp/communities` — proxy к EvoBGP `GET /v1/communities?limit=200` (нужны settings выше). ## Список -Создайте IP list type `evobgp_community` с `config.community_id`. Cron / кнопка Refresh обновляет entries и бампит generation агентов. +Создайте IP list type `evobgp_community` с `config.community_id`. Cron / кнопка Refresh обновляет entries. Firewall-подсистема в EvoBGP **удалена** (hard cutover) — клиенты переустанавливаются на EvoFirewall agents. diff --git a/packages/db/migrations/007_default_action.sql b/packages/db/migrations/007_default_action.sql new file mode 100644 index 0000000..2c97d3e --- /dev/null +++ b/packages/db/migrations/007_default_action.sql @@ -0,0 +1,64 @@ +-- Replace exclusive blacklist/whitelist with default_action (accept|drop). +-- Unified kernel chain: deny → allow → default_action. + +PRAGMA foreign_keys = OFF; + +CREATE TABLE agents_v3 ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + hostname TEXT, + platform TEXT NOT NULL DEFAULT 'linux', + token_prefix TEXT NOT NULL, + token_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + default_action TEXT NOT NULL DEFAULT 'accept', + policy_generation INTEGER NOT NULL DEFAULT 1, + last_seen_at TEXT, + last_seen_ip TEXT, + last_apply_at TEXT, + last_apply_status TEXT, + last_apply_error TEXT, + last_apply_prefix_count INTEGER DEFAULT 0, + last_apply_packets_dropped INTEGER NOT NULL DEFAULT 0, + last_apply_packets_accepted INTEGER NOT NULL DEFAULT 0, + last_apply_kernel_method TEXT, + client_version TEXT, + settings_json TEXT NOT NULL DEFAULT '{}', + created_by_user_id TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + approved_at TEXT, + revoked_at TEXT, + CHECK (status IN ('invited', 'pending', 'approved', 'revoked')), + CHECK (platform IN ('linux', 'mikrotik')), + CHECK (default_action IN ('accept', 'drop')), + CHECK (length(trim(name)) > 0) +); + +INSERT INTO agents_v3 ( + id, name, hostname, platform, token_prefix, token_hash, status, default_action, + policy_generation, last_seen_at, last_seen_ip, last_apply_at, last_apply_status, + last_apply_error, last_apply_prefix_count, last_apply_packets_dropped, + last_apply_packets_accepted, last_apply_kernel_method, client_version, + settings_json, created_by_user_id, created_at, approved_at, revoked_at +) +SELECT + id, name, hostname, platform, token_prefix, token_hash, status, + CASE + WHEN policy_mode = 'whitelist' THEN 'drop' + WHEN policy_mode = 'drop' THEN 'drop' + WHEN policy_mode = 'accept' THEN 'accept' + ELSE 'accept' + END, + policy_generation, last_seen_at, last_seen_ip, last_apply_at, last_apply_status, + last_apply_error, last_apply_prefix_count, last_apply_packets_dropped, + last_apply_packets_accepted, last_apply_kernel_method, client_version, + settings_json, created_by_user_id, created_at, approved_at, revoked_at +FROM agents; + +DROP TABLE agents; +ALTER TABLE agents_v3 RENAME TO agents; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_token_hash ON agents (token_hash); +CREATE INDEX IF NOT EXISTS idx_agents_status ON agents (status); + +PRAGMA foreign_keys = ON; diff --git a/packages/db/src/repositories/index.ts b/packages/db/src/repositories/index.ts index e9c8032..fa2d60f 100644 --- a/packages/db/src/repositories/index.ts +++ b/packages/db/src/repositories/index.ts @@ -215,22 +215,12 @@ export function listSetsForAgent(db: Db, agentId: string) { .all() } -/** Replace agent↔set assignments; set_ids order = sort. All sets must share policy_mode. */ +/** Replace agent↔set assignments; set_ids order = sort. */ 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') + for (const setId of setIds) { + if (!getPolicySet(db, setId)) { + throw new Error(`policy set not found: ${setId}`) } - 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() @@ -498,7 +488,7 @@ export function cloneRulesFrom( } updateAgent(db, targetAgentId, { - policyMode: source.policyMode, + defaultAction: source.defaultAction, policyGeneration: (target.policyGeneration ?? 1) + 1, }) return getAgent(db, targetAgentId) diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3b34acd..47c2b14 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -19,7 +19,8 @@ export const agents = sqliteTable( tokenPrefix: text('token_prefix').notNull(), tokenHash: text('token_hash').notNull(), status: text('status').notNull().default('pending'), // invited | pending | approved | revoked - policyMode: text('policy_mode').notNull().default('blacklist'), // blacklist | whitelist + /** Packet default when not in deny/allow sets: accept | drop */ + defaultAction: text('default_action').notNull().default('accept'), policyGeneration: integer('policy_generation').notNull().default(1), lastSeenAt: text('last_seen_at'), lastSeenIp: text('last_seen_ip'), @@ -84,7 +85,8 @@ 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 + /** Legacy unused; sets no longer carry exclusive mode. */ + policyMode: text('policy_mode').notNull().default('blacklist'), createdAt: text('created_at') .notNull() .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 5951665..ca6af6b 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -7,7 +7,13 @@ export const agentStatusSchema = z.enum([ 'approved', 'revoked', ]) + +/** Packet default when CIDR is in neither deny nor allow set. */ +export const defaultActionSchema = z.enum(['accept', 'drop']) + +/** @deprecated Use defaultActionSchema. Kept for API input compat. */ export const policyModeSchema = z.enum(['blacklist', 'whitelist']) + export const policyActionSchema = z.enum(['allow', 'deny']) export const ipListTypeSchema = z.enum([ 'static', @@ -16,6 +22,21 @@ export const ipListTypeSchema = z.enum([ 'evobgp_community', ]) +/** Map legacy blacklist/whitelist → default_action. */ +export function defaultActionFromLegacyMode( + mode: string | null | undefined, +): 'accept' | 'drop' { + if (mode === 'whitelist' || mode === 'drop') return 'drop' + return 'accept' +} + +/** Optional mirror for old agent binaries. */ +export function legacyModeFromDefaultAction( + action: 'accept' | 'drop', +): 'blacklist' | 'whitelist' { + return action === 'drop' ? 'whitelist' : 'blacklist' +} + export const agentSchema = z.object({ id: z.string(), name: z.string(), @@ -23,7 +44,9 @@ export const agentSchema = z.object({ platform: agentPlatformSchema, token_prefix: z.string(), status: agentStatusSchema, - policy_mode: policyModeSchema, + default_action: defaultActionSchema, + /** @deprecated mirror of default_action for older clients */ + policy_mode: policyModeSchema.optional(), policy_generation: z.number().int(), last_seen_at: z.string().nullable().optional(), last_seen_ip: z.string().nullable().optional(), @@ -76,7 +99,8 @@ export const policySetSchema = z.object({ name: z.string(), description: z.string().nullable().optional(), enabled: z.boolean(), - policy_mode: policyModeSchema, + /** @deprecated ignored — sets have no exclusive mode */ + policy_mode: policyModeSchema.optional(), rules_count: z.number().int().optional(), agents_count: z.number().int().optional(), created_at: z.string(), @@ -104,13 +128,15 @@ 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'), + /** @deprecated ignored */ + policy_mode: policyModeSchema.optional(), }) export const patchPolicySetBodySchema = z.object({ name: z.string().min(1).optional(), description: z.string().nullable().optional(), enabled: z.boolean().optional(), + /** @deprecated ignored */ policy_mode: policyModeSchema.optional(), }) @@ -158,11 +184,26 @@ export const createOverrideBodySchema = z.object({ comment: z.string().nullable().optional(), }) -export const patchAgentBodySchema = z.object({ - name: z.string().min(1).optional(), - policy_mode: policyModeSchema.optional(), - settings: z.record(z.string(), z.unknown()).optional(), -}) +export const patchAgentBodySchema = z + .object({ + name: z.string().min(1).optional(), + default_action: defaultActionSchema.optional(), + /** @deprecated use default_action */ + policy_mode: policyModeSchema.optional(), + settings: z.record(z.string(), z.unknown()).optional(), + }) + .transform((v) => { + const default_action = + v.default_action ?? + (v.policy_mode !== undefined + ? defaultActionFromLegacyMode(v.policy_mode) + : undefined) + return { + name: v.name, + default_action, + settings: v.settings, + } + }) export const cloneFromBodySchema = z.object({ include_overrides: z.boolean().optional().default(false), @@ -190,12 +231,47 @@ export const applyReportBodySchema = z.object({ export const agentPolicySchema = z.object({ generation: z.number().int(), hash: z.string(), - policy_mode: policyModeSchema, + apply_version: z.number().int(), + default_action: defaultActionSchema, + /** @deprecated mirror for old agents */ + policy_mode: policyModeSchema.optional(), deny_cidrs: z.array(z.string()), allow_cidrs: z.array(z.string()), sync_interval_sec: z.number().int(), }) +export const agentPolicyPreviewSchema = z.object({ + default_action: defaultActionSchema, + hash: z.string(), + generation: z.number().int(), + sync_interval_sec: z.number().int(), + apply_version: z.literal(2), + summary: z.object({ + sets: z.number().int(), + rules_deny: z.number().int(), + rules_allow: z.number().int(), + cidrs_deny: z.number().int(), + cidrs_allow: z.number().int(), + overrides: z.number().int(), + conflicts_dropped: z.number().int(), + }), + chain: z.array( + z.object({ + set_id: z.string().nullable(), + set_name: z.string().nullable(), + rule_id: z.string().nullable(), + action: policyActionSchema, + source_kind: z.enum(['list', 'cidr', 'hostname', 'override']), + source_label: z.string(), + cidr_count: z.number().int(), + }), + ), + deny_cidrs: z.array(z.string()), + allow_cidrs: z.array(z.string()), + deny_cidrs_total: z.number().int(), + allow_cidrs_total: z.number().int(), +}) + export const dashboardStatsSchema = z.object({ agents_total: z.number().int(), agents_approved: z.number().int(), @@ -235,11 +311,20 @@ export const installLinkSchema = z.object({ .optional(), }) +export const evobgpCommunitySchema = z.object({ + id: z.string(), + community: z.string(), + title: z.string().nullable().optional(), +}) + export type Agent = z.infer export type IpList = z.infer export type PolicyRule = z.infer export type PolicySet = z.infer export type IpOverride = z.infer export type AgentPolicy = z.infer +export type AgentPolicyPreview = z.infer export type DashboardStats = z.infer export type InstallLink = z.infer +export type EvobgpCommunity = z.infer +export type DefaultAction = z.infer diff --git a/packages/ui/src/components/scroll-area.tsx b/packages/ui/src/components/scroll-area.tsx index ff28786..8aa01ec 100644 --- a/packages/ui/src/components/scroll-area.tsx +++ b/packages/ui/src/components/scroll-area.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"