diff --git a/apps/api/src/routes/control.ts b/apps/api/src/routes/control.ts index 960dfc3..491af33 100644 --- a/apps/api/src/routes/control.ts +++ b/apps/api/src/routes/control.ts @@ -4,12 +4,19 @@ import { createOverrideBodySchema, createIpListBodySchema, createPolicyRuleBodySchema, + createPolicySetBodySchema, + patchPolicySetBodySchema, + putAgentPolicySetsBodySchema, patchAgentBodySchema, cloneFromBodySchema, } from '@evofw/shared' import { AppError } from '../plugins/error-handler.js' import { refreshIpList } from '../services/lists/refresh.js' import { evaluateAgentPolicy } from '../services/policy/evaluate.js' +import { + resolveAndStoreHostnameRule, + resolveHostnameToCidrs, +} from '../services/policy/resolve-hostname.js' import type { AppConfig } from '../config.js' function mapAgent(a: NonNullable>) { @@ -38,6 +45,43 @@ function mapAgent(a: NonNullable>) { } } +function mapPolicySet( + s: NonNullable>, + db: Parameters[0], +) { + return { + id: s.id, + name: s.name, + description: s.description, + enabled: s.enabled === 1, + rules_count: repos.countRulesInSet(db, s.id), + agents_count: repos.countAgentsForSet(db, s.id), + created_at: s.createdAt, + updated_at: s.updatedAt, + } +} + +function mapPolicyRule( + r: NonNullable>, + db: Parameters[0], +) { + return { + id: r.id, + set_id: r.setId, + priority: r.priority, + action: r.action, + list_id: r.listId, + cidr: r.cidr, + hostname: r.hostname, + resolved_count: r.hostname + ? repos.listResolvedForRule(db, r.id).length + : undefined, + comment: r.comment, + created_at: r.createdAt, + updated_at: r.updatedAt, + } +} + export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( app, opts, @@ -131,6 +175,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( status: 'approved', approvedAt: new Date().toISOString(), }) + repos.ensureSharedSetAssigned(app.db, a.id) return mapAgent(updated!) }) @@ -294,70 +339,195 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( return { ok: true } }) - // Rules - app.get<{ Querystring: { agent_id?: string } }>('/rules', async (req) => { - const agentId = - req.query.agent_id === 'tenant' || req.query.agent_id === '' - ? null - : req.query.agent_id - const items = ( - agentId === undefined - ? repos.listPolicyRules(app.db) - : repos.listPolicyRules(app.db, agentId) - ).map((r) => ({ - id: r.id, - agent_id: r.agentId, - priority: r.priority, - action: r.action, - list_id: r.listId, - cidr: r.cidr, - comment: r.comment, - created_at: r.createdAt, - updated_at: r.updatedAt, - })) - return { items } + // Policy sets + app.get('/policy-sets', async () => ({ + items: repos.listPolicySets(app.db).map((s) => mapPolicySet(s, app.db)), + })) + + app.get<{ Params: { id: string } }>('/policy-sets/:id', async (req) => { + const s = repos.getPolicySet(app.db, req.params.id) + if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) + return { + ...mapPolicySet(s, app.db), + agent_ids: repos.listAgentIdsForSet(app.db, s.id), + } }) + app.post('/policy-sets', async (req) => { + const body = createPolicySetBodySchema.parse(req.body) + const row = repos.insertPolicySet(app.db, { + id: crypto.randomUUID(), + name: body.name.trim(), + description: body.description ?? null, + enabled: body.enabled === false ? 0 : 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + return mapPolicySet(row!, app.db) + }) + + app.patch<{ Params: { id: string } }>('/policy-sets/:id', async (req) => { + const body = patchPolicySetBodySchema.parse(req.body) + const s = repos.getPolicySet(app.db, req.params.id) + if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) + const updated = repos.updatePolicySet(app.db, s.id, { + name: body.name?.trim(), + description: body.description, + enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0, + }) + if (body.enabled !== undefined) repos.bumpAgentsForSet(app.db, s.id) + return mapPolicySet(updated!, app.db) + }) + + app.delete<{ Params: { id: string } }>('/policy-sets/:id', async (req) => { + try { + const agentIds = repos.listAgentIdsForSet(app.db, req.params.id) + repos.deletePolicySet(app.db, req.params.id) + for (const id of agentIds) repos.bumpAgentGeneration(app.db, id) + } catch (err) { + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } + return { ok: true } + }) + + app.get<{ Params: { id: string } }>( + '/policy-sets/:id/rules', + async (req) => { + const s = repos.getPolicySet(app.db, req.params.id) + if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) + return { + items: repos + .listPolicyRules(app.db, s.id) + .map((r) => mapPolicyRule(r, app.db)), + } + }, + ) + + app.put<{ Params: { id: string } }>( + '/agents/:id/policy-sets', + async (req) => { + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + const body = putAgentPolicySetsBodySchema.parse(req.body) + for (const setId of body.set_ids) { + if (!repos.getPolicySet(app.db, setId)) { + throw new AppError('NOT_FOUND', `Policy set not found: ${setId}`, 404) + } + } + repos.setAgentPolicySets(app.db, a.id, body.set_ids) + return { + items: repos.listSetsForAgent(app.db, a.id).map((s) => ({ + set_id: s.setId, + sort: s.sort, + name: s.name, + description: s.description, + enabled: s.enabled === 1, + })), + } + }, + ) + + app.get<{ Params: { id: string } }>( + '/agents/:id/policy-sets', + async (req) => { + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + return { + items: repos.listSetsForAgent(app.db, a.id).map((s) => ({ + set_id: s.setId, + sort: s.sort, + name: s.name, + description: s.description, + enabled: s.enabled === 1, + })), + } + }, + ) + + // Rules + app.get<{ Querystring: { set_id?: string; agent_id?: string } }>( + '/rules', + async (req) => { + if (req.query.agent_id) { + return { + items: repos + .listPolicyRulesForAgent(app.db, req.query.agent_id) + .map((r) => mapPolicyRule(r, app.db)), + } + } + const items = repos + .listPolicyRules(app.db, req.query.set_id) + .map((r) => mapPolicyRule(r, app.db)) + return { items } + }, + ) + app.post('/rules', async (req) => { const body = createPolicyRuleBodySchema.parse(req.body) - if (!body.list_id && !body.cidr) { - throw new AppError('VALIDATION_ERROR', 'list_id or cidr required') + const set = repos.getPolicySet(app.db, body.set_id) + if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404) + + const hostname = body.hostname?.trim() || null + const cidr = body.cidr?.trim() || null + const listId = body.list_id?.trim() || null + + if (hostname) { + try { + await resolveHostnameToCidrs(hostname) + } catch (err) { + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } } + + if (listId && !repos.getIpList(app.db, listId)) { + throw new AppError('NOT_FOUND', 'IP list not found', 404) + } + + const id = crypto.randomUUID() const row = repos.insertPolicyRule(app.db, { - id: crypto.randomUUID(), - agentId: body.agent_id ?? null, + id, + setId: body.set_id, priority: body.priority, action: body.action, - listId: body.list_id ?? null, - cidr: body.cidr ?? null, + listId, + cidr, + hostname, comment: body.comment ?? null, createdByUserId: req.authUser?.id, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) - if (body.agent_id) repos.bumpAgentGeneration(app.db, body.agent_id) - else { - for (const a of repos.listAgents(app.db)) { - if (a.status === 'approved') repos.bumpAgentGeneration(app.db, a.id) + + if (hostname) { + try { + await resolveAndStoreHostnameRule(app.db, id, hostname) + } catch (err) { + repos.deletePolicyRule(app.db, id) + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) } } - return { - id: row!.id, - agent_id: row!.agentId, - priority: row!.priority, - action: row!.action, - list_id: row!.listId, - cidr: row!.cidr, - comment: row!.comment, - created_at: row!.createdAt, - updated_at: row!.updatedAt, - } + + repos.bumpAgentsForSet(app.db, body.set_id) + return mapPolicyRule(row!, 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) repos.deletePolicyRule(app.db, req.params.id) - if (rule?.agentId) repos.bumpAgentGeneration(app.db, rule.agentId) + repos.bumpAgentsForSet(app.db, rule.setId) return { ok: true } }) diff --git a/apps/api/src/services/lists/refresh.ts b/apps/api/src/services/lists/refresh.ts index 32662b7..43f2e0c 100644 --- a/apps/api/src/services/lists/refresh.ts +++ b/apps/api/src/services/lists/refresh.ts @@ -166,9 +166,12 @@ export async function refreshIpList(db: Db, listId: string): Promise { } } +import { refreshAllHostnameRules } from '../policy/resolve-hostname.js' + export async function refreshAllLists(db: Db): Promise { for (const list of repos.listIpLists(db)) { if (list.type === 'static') continue await refreshIpList(db, list.id) } + await refreshAllHostnameRules(db) } diff --git a/apps/api/src/services/policy/evaluate.ts b/apps/api/src/services/policy/evaluate.ts index 6eb93de..c4631c9 100644 --- a/apps/api/src/services/policy/evaluate.ts +++ b/apps/api/src/services/policy/evaluate.ts @@ -28,26 +28,36 @@ function expandList(db: Db, listId: string | null | undefined): string[] { return repos.listIpListEntries(db, listId).map((e) => e.cidr) } -/** Evaluate allow/deny sets for an agent. */ +function expandRule( + db: Db, + rule: { + cidr: string | null + listId: string | null + hostname: string | null + id: string + }, +): string[] { + if (rule.cidr?.trim()) return [rule.cidr.trim()] + if (rule.hostname?.trim()) { + return repos.listResolvedForRule(db, rule.id).map((r) => r.cidr) + } + return expandList(db, rule.listId) +} + +/** 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) if (!agent) { throw new Error(`agent not found: ${agentId}`) } - const agentRules = repos.listPolicyRules(db, agentId) - const tenantRules = repos.listPolicyRules(db, null) - const ordered = [...agentRules, ...tenantRules].sort( - (a, b) => a.priority - b.priority, - ) + const ordered = repos.listPolicyRulesForAgent(db, agentId) const deny: string[] = [] const allow: string[] = [] for (const rule of ordered) { - const cidrs = rule.cidr - ? [rule.cidr] - : expandList(db, rule.listId) + const cidrs = expandRule(db, rule) if (rule.action === 'deny') deny.push(...cidrs) else allow.push(...cidrs) } diff --git a/apps/api/src/services/policy/resolve-hostname.ts b/apps/api/src/services/policy/resolve-hostname.ts new file mode 100644 index 0000000..50b76d1 --- /dev/null +++ b/apps/api/src/services/policy/resolve-hostname.ts @@ -0,0 +1,79 @@ +import { resolve4, resolve6 } from 'node:dns/promises' +import { createHash } from 'node:crypto' +import type { Db } from '@evofw/db' +import { repos } from '@evofw/db' + +function uniq(cidrs: string[]): string[] { + return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort() +} + +function hashCidrs(cidrs: string[]): string { + return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}` +} + +/** Resolve FQDN to /32 and /128 CIDRs. Throws if nothing resolved. */ +export async function resolveHostnameToCidrs(hostname: string): Promise { + const host = hostname.trim().replace(/\.$/, '').toLowerCase() + if (!host) throw new Error('hostname empty') + + const out: string[] = [] + try { + const a = await resolve4(host) + out.push(...a.map((ip) => `${ip}/32`)) + } catch { + /* ignore A failures */ + } + try { + const aaaa = await resolve6(host) + out.push(...aaaa.map((ip) => `${ip}/128`)) + } catch { + /* ignore AAAA failures */ + } + + const cidrs = uniq(out) + if (cidrs.length === 0) { + throw new Error(`DNS resolve failed for ${host}: no A/AAAA records`) + } + return cidrs +} + +export async function resolveAndStoreHostnameRule( + db: Db, + ruleId: string, + hostname: string, +): Promise { + const cidrs = await resolveHostnameToCidrs(hostname) + const prev = repos + .listResolvedForRule(db, ruleId) + .map((r) => r.cidr) + .sort() + const nextHash = hashCidrs(cidrs) + const prevHash = hashCidrs(prev) + repos.replaceResolvedForRule(db, ruleId, cidrs) + return cidrs.length && nextHash !== prevHash ? cidrs : cidrs +} + +/** Re-resolve all hostname rules; bump agents when cache changes. */ +export async function refreshAllHostnameRules(db: Db): Promise { + const rules = repos.listHostnameRules(db) + const changedSets = new Set() + + for (const rule of rules) { + if (!rule.hostname) continue + try { + const prev = hashCidrs( + repos.listResolvedForRule(db, rule.id).map((r) => r.cidr), + ) + const cidrs = await resolveHostnameToCidrs(rule.hostname) + const next = hashCidrs(cidrs) + repos.replaceResolvedForRule(db, rule.id, cidrs) + if (prev !== next) changedSets.add(rule.setId) + } catch { + /* keep previous cache on transient DNS failure */ + } + } + + for (const setId of changedSets) { + repos.bumpAgentsForSet(db, setId) + } +} diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 06cc5f4..f44103c 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -28,7 +28,7 @@ const overviewNav = [ const opsNav = [ { to: '/agents', label: 'Агенты', icon: ServerIcon, exact: false }, { to: '/lists', label: 'Списки IP', icon: ListIcon, exact: false }, - { to: '/rules', label: 'Правила', icon: ShieldIcon, exact: false }, + { to: '/rules', label: 'Наборы правил', icon: ShieldIcon, exact: false }, { to: '/stats', label: 'Статистика', icon: BarChart3Icon, exact: false }, ] as const diff --git a/apps/web/src/components/layout/search-menu.tsx b/apps/web/src/components/layout/search-menu.tsx index c8be00f..c2d33aa 100644 --- a/apps/web/src/components/layout/search-menu.tsx +++ b/apps/web/src/components/layout/search-menu.tsx @@ -47,8 +47,8 @@ const NAV_ITEMS = [ }, { to: '/rules', - label: 'Правила', - keywords: ['rules', 'правила', 'policy'], + label: 'Наборы правил', + keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'], icon: ShieldIcon, }, { diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index ab221f5..bbd6aa4 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -22,7 +22,7 @@ const routeTitles: Record = { '/': 'Панель управления', '/agents': 'Агенты', '/lists': 'Списки IP', - '/rules': 'Правила', + '/rules': 'Наборы правил', '/stats': 'Статистика', '/settings': 'Настройки', } @@ -42,6 +42,13 @@ function getBreadcrumbs( ] } + if (pathname.match(/^\/rules\/[^/]+$/)) { + return [ + { label: 'Наборы правил', href: '/rules' }, + { label: dynamicLabels[pathname] ?? 'Набор', href: pathname }, + ] + } + const title = routeTitles[pathname] if (title) { return [{ label: title, href: pathname }] diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx index 6643c7e..b74711d 100644 --- a/apps/web/src/components/layout/system-monitor-popover.tsx +++ b/apps/web/src/components/layout/system-monitor-popover.tsx @@ -11,7 +11,7 @@ import { agentsQueryOptions, dashboardQueryOptions, listsQueryOptions, - rulesQueryOptions, + policySetsQueryOptions, } from '@/queries' type MonitorMetric = { @@ -80,7 +80,10 @@ export function SystemMonitorPopover() { const dashQ = useQuery({ ...dashboardQueryOptions(), refetchInterval: 30_000 }) const agentsQ = useQuery({ ...agentsQueryOptions(), refetchInterval: 30_000 }) const listsQ = useQuery({ ...listsQueryOptions(), refetchInterval: 60_000 }) - const rulesQ = useQuery({ ...rulesQueryOptions(), refetchInterval: 60_000 }) + const setsQ = useQuery({ + ...policySetsQueryOptions(), + refetchInterval: 60_000, + }) const d = dashQ.data const agents = agentsQ.data?.items ?? [] @@ -90,7 +93,8 @@ export function SystemMonitorPopover() { d?.agents_pending ?? agents.filter((a) => a.status === 'pending').length const onlinePct = approved > 0 ? Math.round((online / approved) * 100) : 0 const listsCount = listsQ.data?.items?.length ?? d?.lists_total ?? 0 - const rulesCount = rulesQ.data?.items?.length ?? 0 + const rulesCount = + setsQ.data?.items.reduce((n, s) => n + (s.rules_count ?? 0), 0) ?? 0 const apiOk = !dashQ.isError const metrics = useMemo( diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index b455e5d..6ba2fd2 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -1,6 +1,12 @@ import { queryOptions } from '@tanstack/react-query' import { apiFetch } from '@/lib/api' -import type { Agent, DashboardStats, IpList, PolicyRule } from '@evofw/shared' +import type { + Agent, + DashboardStats, + IpList, + PolicyRule, + PolicySet, +} from '@evofw/shared' export const dashboardQueryOptions = () => queryOptions({ @@ -26,15 +32,45 @@ export const listsQueryOptions = () => queryFn: () => apiFetch<{ items: IpList[] }>('/api/v1/lists'), }) -export const rulesQueryOptions = (agentId?: string) => +export const policySetsQueryOptions = () => queryOptions({ - queryKey: ['rules', agentId ?? 'all'], + queryKey: ['policy-sets'], + queryFn: () => apiFetch<{ items: PolicySet[] }>('/api/v1/policy-sets'), + }) + +export const policySetQueryOptions = (id: string) => + queryOptions({ + queryKey: ['policy-sets', id], + queryFn: () => + apiFetch( + `/api/v1/policy-sets/${id}`, + ), + }) + +export const policySetRulesQueryOptions = (setId: string) => + queryOptions({ + queryKey: ['policy-sets', setId, 'rules'], queryFn: () => apiFetch<{ items: PolicyRule[] }>( - `/api/v1/rules${agentId ? `?agent_id=${encodeURIComponent(agentId)}` : ''}`, + `/api/v1/policy-sets/${setId}/rules`, ), }) +export const agentPolicySetsQueryOptions = (agentId: string) => + queryOptions({ + queryKey: ['agents', agentId, 'policy-sets'], + queryFn: () => + apiFetch<{ + items: { + set_id: string + sort: number + name: string + description?: string | null + enabled: boolean + }[] + }>(`/api/v1/agents/${agentId}/policy-sets`), + }) + export const installContextQueryOptions = () => queryOptions({ queryKey: ['install-context'], diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 231d618..a1f71e4 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as AuthSettingsRouteImport } from './routes/_auth/settings' import { Route as AuthStatsRouteImport } from './routes/_auth/stats' import { Route as AuthCallbackRouteImport } from './routes/auth.callback' import { Route as AuthAgentsIdRouteImport } from './routes/_auth/agents.$id' +import { Route as AuthRulesSetIdRouteImport } from './routes/_auth/rules.$setId' const AuthRoute = AuthRouteImport.update({ id: '/_auth', @@ -63,38 +64,46 @@ const AuthAgentsIdRoute = AuthAgentsIdRouteImport.update({ path: '/$id', getParentRoute: () => AuthAgentsRoute, } as any) +const AuthRulesSetIdRoute = AuthRulesSetIdRouteImport.update({ + id: '/$setId', + path: '/$setId', + getParentRoute: () => AuthRulesRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof AuthIndexRoute '/agents': typeof AuthAgentsRouteWithChildren '/lists': typeof AuthListsRoute - '/rules': typeof AuthRulesRoute + '/rules': typeof AuthRulesRouteWithChildren '/settings': typeof AuthSettingsRoute '/stats': typeof AuthStatsRoute '/auth/callback': typeof AuthCallbackRoute '/agents/$id': typeof AuthAgentsIdRoute + '/rules/$setId': typeof AuthRulesSetIdRoute } export interface FileRoutesByTo { '/agents': typeof AuthAgentsRouteWithChildren '/lists': typeof AuthListsRoute - '/rules': typeof AuthRulesRoute + '/rules': typeof AuthRulesRouteWithChildren '/settings': typeof AuthSettingsRoute '/stats': typeof AuthStatsRoute '/auth/callback': typeof AuthCallbackRoute '/': typeof AuthIndexRoute '/agents/$id': typeof AuthAgentsIdRoute + '/rules/$setId': typeof AuthRulesSetIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_auth': typeof AuthRouteWithChildren '/_auth/agents': typeof AuthAgentsRouteWithChildren '/_auth/lists': typeof AuthListsRoute - '/_auth/rules': typeof AuthRulesRoute + '/_auth/rules': typeof AuthRulesRouteWithChildren '/_auth/settings': typeof AuthSettingsRoute '/_auth/stats': typeof AuthStatsRoute '/auth/callback': typeof AuthCallbackRoute '/_auth/': typeof AuthIndexRoute '/_auth/agents/$id': typeof AuthAgentsIdRoute + '/_auth/rules/$setId': typeof AuthRulesSetIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -107,6 +116,7 @@ export interface FileRouteTypes { | '/stats' | '/auth/callback' | '/agents/$id' + | '/rules/$setId' fileRoutesByTo: FileRoutesByTo to: | '/agents' @@ -117,6 +127,7 @@ export interface FileRouteTypes { | '/auth/callback' | '/' | '/agents/$id' + | '/rules/$setId' id: | '__root__' | '/_auth' @@ -128,6 +139,7 @@ export interface FileRouteTypes { | '/auth/callback' | '/_auth/' | '/_auth/agents/$id' + | '/_auth/rules/$setId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -200,6 +212,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthAgentsIdRouteImport parentRoute: typeof AuthAgentsRoute } + '/_auth/rules/$setId': { + id: '/_auth/rules/$setId' + path: '/$setId' + fullPath: '/rules/$setId' + preLoaderRoute: typeof AuthRulesSetIdRouteImport + parentRoute: typeof AuthRulesRoute + } } } @@ -215,10 +234,22 @@ const AuthAgentsRouteWithChildren = AuthAgentsRoute._addFileChildren( AuthAgentsRouteChildren, ) +interface AuthRulesRouteChildren { + AuthRulesSetIdRoute: typeof AuthRulesSetIdRoute +} + +const AuthRulesRouteChildren: AuthRulesRouteChildren = { + AuthRulesSetIdRoute: AuthRulesSetIdRoute, +} + +const AuthRulesRouteWithChildren = AuthRulesRoute._addFileChildren( + AuthRulesRouteChildren, +) + interface AuthRouteChildren { AuthAgentsRoute: typeof AuthAgentsRouteWithChildren AuthListsRoute: typeof AuthListsRoute - AuthRulesRoute: typeof AuthRulesRoute + AuthRulesRoute: typeof AuthRulesRouteWithChildren AuthSettingsRoute: typeof AuthSettingsRoute AuthStatsRoute: typeof AuthStatsRoute AuthIndexRoute: typeof AuthIndexRoute @@ -227,7 +258,7 @@ interface AuthRouteChildren { const AuthRouteChildren: AuthRouteChildren = { AuthAgentsRoute: AuthAgentsRouteWithChildren, AuthListsRoute: AuthListsRoute, - AuthRulesRoute: AuthRulesRoute, + AuthRulesRoute: AuthRulesRouteWithChildren, AuthSettingsRoute: AuthSettingsRoute, AuthStatsRoute: AuthStatsRoute, AuthIndexRoute: AuthIndexRoute, diff --git a/apps/web/src/routes/_auth/agents.$id.tsx b/apps/web/src/routes/_auth/agents.$id.tsx index e1f4c7e..3b63098 100644 --- a/apps/web/src/routes/_auth/agents.$id.tsx +++ b/apps/web/src/routes/_auth/agents.$id.tsx @@ -1,8 +1,7 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { useMemo, useState } from 'react' -import type { ColumnDef } from '@tanstack/react-table' +import { useEffect, useState } from 'react' import { BanIcon, CheckCircle2Icon, @@ -21,10 +20,12 @@ import { Badge } from '@/components/reui/badge' import { agentQueryOptions, agentsQueryOptions, - rulesQueryOptions, + agentPolicySetsQueryOptions, + policySetsQueryOptions, } from '@/queries' import { apiFetch } from '@/lib/api' import { Button } from '@evofw/ui/components/button' +import { Checkbox } from '@evofw/ui/components/checkbox' import { Input } from '@evofw/ui/components/input' import { Label } from '@evofw/ui/components/label' import { @@ -34,10 +35,6 @@ import { SelectTrigger, SelectValue, } from '@evofw/ui/components/select' -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 { PolicyRule } from '@evofw/shared' import { Skeleton } from '@evofw/ui/components/skeleton' export const Route = createFileRoute('/_auth/agents/$id')({ @@ -48,11 +45,20 @@ function AgentDetailPage() { const { id } = Route.useParams() const qc = useQueryClient() const agentQ = useQuery(agentQueryOptions(id)) - const rulesQ = useQuery(rulesQueryOptions(id)) + const setsQ = useQuery(policySetsQueryOptions()) + const assignedQ = useQuery(agentPolicySetsQueryOptions(id)) const agentsQ = useQuery(agentsQueryOptions()) const [cidr, setCidr] = useState('') const [action, setAction] = useState<'allow' | 'deny'>('deny') const [cloneFrom, setCloneFrom] = useState('') + const [selectedSets, setSelectedSets] = useState(null) + + useEffect(() => { + setSelectedSets(null) + }, [id, assignedQ.data]) + + const assignedIds = + selectedSets ?? assignedQ.data?.items.map((i) => i.set_id) ?? [] const patchMode = useMutation({ mutationFn: (policy_mode: 'blacklist' | 'whitelist') => @@ -80,6 +86,21 @@ function AgentDetailPage() { onError: (e: Error) => toast.error(e.message), }) + const saveSets = useMutation({ + mutationFn: () => + apiFetch(`/api/v1/agents/${id}/policy-sets`, { + method: 'PUT', + body: JSON.stringify({ set_ids: assignedIds }), + }), + onSuccess: () => { + toast.success('Наборы сохранены') + setSelectedSets(null) + void qc.invalidateQueries({ queryKey: ['agents', id] }) + void qc.invalidateQueries({ queryKey: ['policy-sets'] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + const clone = useMutation({ mutationFn: () => apiFetch(`/api/v1/agents/${id}/clone-from/${cloneFrom}`, { @@ -87,52 +108,13 @@ function AgentDetailPage() { body: JSON.stringify({ include_overrides: true }), }), onSuccess: () => { - toast.success('Правила скопированы') - void qc.invalidateQueries({ queryKey: ['rules'] }) - void qc.invalidateQueries({ queryKey: ['agents'] }) + toast.success('Наборы скопированы') + void qc.invalidateQueries({ queryKey: ['agents', id] }) + void qc.invalidateQueries({ queryKey: ['policy-sets'] }) }, onError: (e: Error) => toast.error(e.message), }) - const ruleColumns: ColumnDef[] = useMemo( - () => [ - { accessorKey: 'priority', header: 'Prio' }, - { - accessorKey: 'action', - header: 'Action', - cell: ({ row }) => ( - - {row.original.action} - - ), - }, - { - id: 'source', - header: 'Source', - cell: ({ row }) => ( - - {row.original.cidr ?? row.original.list_id ?? '—'} - - ), - }, - ], - [], - ) - - const rulesTable = useReactTable({ - data: rulesQ.data?.items ?? [], - columns: ruleColumns, - getCoreRowModel: getCoreRowModel(), - getRowId: (r) => r.id, - }) - const a = agentQ.data if (agentQ.isLoading || !a) { return ( @@ -231,6 +213,59 @@ function AgentDetailPage() { + + + Наборы правил + + Можно назначить несколько — мержатся при sync + + + +
+ {(setsQ.data?.items ?? []).map((s) => { + const checked = assignedIds.includes(s.id) + return ( + + ) + })} +
+ +
+ + Мгновенный IP override @@ -277,7 +312,10 @@ function AgentDetailPage() { - Копировать правила + Копировать наборы + + Копирует назначения наборов (+ overrides) с другого агента +
@@ -308,20 +346,6 @@ function AgentDetailPage() {
- - - - Правила агента - - - - - - - diff --git a/apps/web/src/routes/_auth/rules.$setId.tsx b/apps/web/src/routes/_auth/rules.$setId.tsx new file mode 100644 index 0000000..023867b --- /dev/null +++ b/apps/web/src/routes/_auth/rules.$setId.tsx @@ -0,0 +1,461 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { useMemo, useState } from 'react' +import type { ColumnDef } from '@tanstack/react-table' +import { + PageHeader, + PageShell, + DetailPanel, +} from '@/components/reui-kit' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Badge } from '@/components/reui/badge' +import { + agentsQueryOptions, + policySetQueryOptions, + policySetRulesQueryOptions, +} from '@/queries' +import { apiFetch } from '@/lib/api' +import { Button } from '@evofw/ui/components/button' +import { Checkbox } from '@evofw/ui/components/checkbox' +import { Field, FieldLabel } from '@evofw/ui/components/field' +import { Input } from '@evofw/ui/components/input' +import { Switch } from '@evofw/ui/components/switch' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@evofw/ui/components/select' +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} 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 { PolicyRule } from '@evofw/shared' +import { listsQueryOptions } from '@/queries' +import { Skeleton } from '@evofw/ui/components/skeleton' + +export const Route = createFileRoute('/_auth/rules/$setId')({ + component: PolicySetDetailPage, +}) + +type SourceKind = 'list' | 'cidr' | 'hostname' + +/** + * Policy set detail — Frame + DataGrid + sheet-8 create rule. + * Preview: https://reui.io/preview/base/sheet-8 · https://reui.io/preview/base/data-grid-filtering-2 + */ +function PolicySetDetailPage() { + const { setId } = Route.useParams() + const qc = useQueryClient() + const setQ = useQuery(policySetQueryOptions(setId)) + const rulesQ = useQuery(policySetRulesQueryOptions(setId)) + const agentsQ = useQuery(agentsQueryOptions()) + 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 assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? [] + + const patchSet = useMutation({ + mutationFn: (body: { enabled?: boolean; name?: string }) => + apiFetch(`/api/v1/policy-sets/${setId}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), + onSuccess: () => { + toast.success('Набор обновлён') + void qc.invalidateQueries({ queryKey: ['policy-sets'] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + const saveAgents = useMutation({ + mutationFn: async () => { + // Assign this set to selected agents: merge with their other sets + const allAgents = agentsQ.data?.items ?? [] + await Promise.all( + allAgents.map(async (a) => { + const current = await apiFetch<{ + items: { set_id: string }[] + }>(`/api/v1/agents/${a.id}/policy-sets`) + const others = current.items + .map((i) => i.set_id) + .filter((id) => id !== setId) + const next = assignedIds.includes(a.id) + ? [...others, setId] + : others + await apiFetch(`/api/v1/agents/${a.id}/policy-sets`, { + method: 'PUT', + body: JSON.stringify({ set_ids: next }), + }) + }), + ) + }, + onSuccess: () => { + toast.success('Назначение агентов сохранено') + setSelectedAgents(null) + void qc.invalidateQueries({ queryKey: ['policy-sets'] }) + void qc.invalidateQueries({ queryKey: ['agents'] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + const createRule = useMutation({ + mutationFn: () => { + const body: Record = { + set_id: setId, + priority: Number(priority), + action, + } + if (source === 'list') body.list_id = listId + if (source === 'cidr') body.cidr = cidr.trim() + if (source === 'hostname') body.hostname = hostname.trim() + return apiFetch('/api/v1/rules', { + method: 'POST', + body: JSON.stringify(body), + }) + }, + onSuccess: () => { + toast.success('Правило создано') + setRuleOpen(false) + setCidr('') + setHostname('') + setListId('') + void qc.invalidateQueries({ queryKey: ['policy-sets', setId] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + const removeRule = useMutation({ + mutationFn: (id: string) => + apiFetch(`/api/v1/rules/${id}`, { method: 'DELETE' }), + onSuccess: () => { + toast.success('Удалено') + void qc.invalidateQueries({ queryKey: ['policy-sets', setId] }) + }, + }) + + const rules = rulesQ.data?.items ?? [] + + const columns: ColumnDef[] = useMemo( + () => [ + { accessorKey: 'priority', header: 'Prio' }, + { + accessorKey: 'action', + header: 'Action', + cell: ({ row }) => ( + + {row.original.action} + + ), + }, + { + id: 'source', + header: 'Источник', + cell: ({ row }) => { + const r = row.original + if (r.hostname) { + return ( + + DNS {r.hostname} + {typeof r.resolved_count === 'number' + ? ` (${r.resolved_count} IP)` + : ''} + + ) + } + if (r.cidr) { + return {r.cidr} + } + return ( + + list:{r.list_id?.slice(0, 8)}… + + ) + }, + }, + { + id: 'actions', + cell: ({ row }) => ( +
+ +
+ ), + }, + ], + [removeRule], + ) + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (r) => r.id, + }) + + const canCreate = + source === 'list' + ? Boolean(listId) + : source === 'cidr' + ? Boolean(cidr.trim()) + : Boolean(hostname.trim()) + + if (setQ.isLoading) { + return ( + + + + + ) + } + + if (!setQ.data) { + return ( + + + + + ) + } + + const set = setQ.data + + return ( + + +
+ patchSet.mutate({ enabled: v })} + /> + + {set.enabled ? 'Включён' : 'Выключен'} + +
+ + + + } + /> + + + + + + + + + + + + + + + + Агенты + + Отметьте, каким агентам применять этот набор + + + +
+ {(agentsQ.data?.items ?? []) + .filter((a) => a.status === 'approved') + .map((a) => { + const checked = assignedIds.includes(a.id) + return ( + + ) + })} + {(agentsQ.data?.items ?? []).filter((a) => a.status === 'approved') + .length === 0 ? ( +

+ Нет approved-агентов +

+ ) : null} +
+ +
+ +
+
+ + + + + Новое правило + + Один источник: список, CIDR или DNS-имя + + +
+ + Priority + setPriority(e.target.value)} + /> + + + Action + + + + Источник + + + {source === 'list' ? ( + + Список + + + ) : null} + {source === 'cidr' ? ( + + CIDR + setCidr(e.target.value)} + placeholder="203.0.113.0/24" + /> + + ) : null} + {source === 'hostname' ? ( + + DNS-имя + setHostname(e.target.value)} + placeholder="bad.example.com" + /> + + ) : null} +
+ + + + +
+
+
+ ) +} diff --git a/apps/web/src/routes/_auth/rules.tsx b/apps/web/src/routes/_auth/rules.tsx index 2b3bae6..da51450 100644 --- a/apps/web/src/routes/_auth/rules.tsx +++ b/apps/web/src/routes/_auth/rules.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { useCallback, useMemo, useState } from 'react' @@ -6,22 +6,12 @@ import type { ColumnDef } from '@tanstack/react-table' import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit' import { Badge } from '@/components/reui/badge' -import { - rulesQueryOptions, - listsQueryOptions, - agentsQueryOptions, -} from '@/queries' +import { policySetsQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' import { Button } from '@evofw/ui/components/button' import { Field, FieldLabel } from '@evofw/ui/components/field' import { Input } from '@evofw/ui/components/input' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@evofw/ui/components/select' +import { Textarea } from '@evofw/ui/components/textarea' import { Sheet, SheetContent, @@ -30,117 +20,139 @@ import { SheetHeader, SheetTitle, } from '@evofw/ui/components/sheet' -import type { PolicyRule } from '@evofw/shared' +import type { PolicySet } from '@evofw/shared' export const Route = createFileRoute('/_auth/rules')({ - component: RulesPage, + component: PolicySetsPage, }) -function RulesPage() { +/** + * Policy sets list — ReUI ResourcePage. + * Preview: https://reui.io/preview/base/data-grid-filtering-2 + * Empty: https://reui.io/preview/base/empty-state-5 + * Create sheet: https://reui.io/preview/base/sheet-8 + */ +function PolicySetsPage() { + const navigate = useNavigate() const qc = useQueryClient() - const rulesQ = useQuery(rulesQueryOptions()) - const listsQ = useQuery(listsQueryOptions()) - const agentsQ = useQuery(agentsQueryOptions()) + const setsQ = useQuery(policySetsQueryOptions()) const [sheetOpen, setSheetOpen] = useState(false) - const [priority, setPriority] = useState('100') - const [action, setAction] = useState<'allow' | 'deny'>('deny') - const [listId, setListId] = useState('') - const [agentId, setAgentId] = useState('tenant') + const [name, setName] = useState('') + const [description, setDescription] = useState('') const [filters, setFilters] = useState([]) const create = useMutation({ mutationFn: () => - apiFetch('/api/v1/rules', { + apiFetch('/api/v1/policy-sets', { method: 'POST', body: JSON.stringify({ - priority: Number(priority), - action, - list_id: listId || null, - agent_id: agentId === 'tenant' ? null : agentId, + name, + description: description.trim() || null, + enabled: true, }), }), - onSuccess: () => { - toast.success('Правило создано') + onSuccess: (row) => { + toast.success('Набор создан') + setName('') + setDescription('') setSheetOpen(false) - void qc.invalidateQueries({ queryKey: ['rules'] }) + void qc.invalidateQueries({ queryKey: ['policy-sets'] }) + void navigate({ to: '/rules/$setId', params: { setId: row.id } }) }, onError: (e: Error) => toast.error(e.message), }) const remove = useMutation({ mutationFn: (id: string) => - apiFetch(`/api/v1/rules/${id}`, { method: 'DELETE' }), - onSuccess: () => void qc.invalidateQueries({ queryKey: ['rules'] }), + apiFetch(`/api/v1/policy-sets/${id}`, { method: 'DELETE' }), + onSuccess: () => { + toast.success('Набор удалён') + void qc.invalidateQueries({ queryKey: ['policy-sets'] }) + }, + onError: (e: Error) => toast.error(e.message), }) - const items = rulesQ.data?.items ?? [] + const items = setsQ.data?.items ?? [] const filterFields: FilterFieldConfig[] = useMemo( () => [ + { key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' }, { - key: 'action', - label: 'Action', + key: 'enabled', + label: 'Статус', type: 'select', options: [ - { value: 'deny', label: 'deny' }, - { value: 'allow', label: 'allow' }, + { value: 'true', label: 'Включён' }, + { value: 'false', label: 'Выключен' }, ], }, ], [], ) - const getFilterFieldValue = useCallback((item: PolicyRule, field: string) => { - if (field === 'action') return item.action + const getFilterFieldValue = useCallback((item: PolicySet, field: string) => { + if (field === 'name') return item.name + if (field === 'enabled') return String(item.enabled) return undefined }, []) - const columns: ColumnDef[] = useMemo( + const columns: ColumnDef[] = useMemo( () => [ - { accessorKey: 'priority', header: 'Prio' }, { - accessorKey: 'action', - header: 'Action', + accessorKey: 'name', + header: 'Набор', + cell: ({ row }) => ( + + {row.original.name} + + ), + }, + { + accessorKey: 'enabled', + header: 'Статус', cell: ({ row }) => ( - {row.original.action} + {row.original.enabled ? 'Включён' : 'Выключен'} ), }, { - accessorKey: 'agent_id', - header: 'Agent', + accessorKey: 'rules_count', + header: 'Правила', cell: ({ row }) => ( - {row.original.agent_id ?? 'tenant'} + {row.original.rules_count ?? 0} ), }, { - id: 'source', - header: 'List / CIDR', + accessorKey: 'agents_count', + header: 'Агенты', cell: ({ row }) => ( - - {row.original.cidr ?? row.original.list_id ?? '—'} - + {row.original.agents_count ?? 0} ), }, { id: 'actions', cell: ({ row }) => ( -
- + {row.original.id !== 'set-shared-default' ? ( + + ) : null}
), }, @@ -151,15 +163,15 @@ function RulesPage() { return ( setSheetOpen(true)}>Новое правило + } /> setFilters([])} getFilterFieldValue={getFilterFieldValue} - isLoading={rulesQ.isLoading} - isError={rulesQ.isError} - error={rulesQ.error} - onRetry={() => void rulesQ.refetch()} + isLoading={setsQ.isLoading} + isError={setsQ.isError} + error={setsQ.error} + onRetry={() => void setsQ.refetch()} emptyState={{ - title: 'Нет правил', - description: 'Создайте первое правило политики.', + title: 'Нет наборов', + description: 'Создайте первый набор правил политики.', action: ( - + ), }} /> @@ -185,71 +197,29 @@ function RulesPage() { - Новое правило - Priority + action + list scope + Новый набор + + После создания добавьте правила и назначьте набор агентам. + -
+
- Priority + Имя setPriority(e.target.value)} + id="set-name" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="Web deny" /> - Action - - - - Список - - - - Scope - + Описание +