feat(policy): именованные наборы правил с DNS и M:N привязкой к агентам
Правила живут в policy_sets; evaluate мержит назначенные наборы; источник list|CIDR|hostname с кэшем DNS. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+214
-44
@@ -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<ReturnType<typeof repos.getAgent>>) {
|
||||
@@ -38,6 +45,43 @@ function mapAgent(a: NonNullable<ReturnType<typeof repos.getAgent>>) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapPolicySet(
|
||||
s: NonNullable<ReturnType<typeof repos.getPolicySet>>,
|
||||
db: Parameters<typeof repos.countRulesInSet>[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<ReturnType<typeof repos.getPolicyRule>>,
|
||||
db: Parameters<typeof repos.listResolvedForRule>[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 }
|
||||
})
|
||||
|
||||
|
||||
@@ -166,9 +166,12 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
import { refreshAllHostnameRules } from '../policy/resolve-hostname.js'
|
||||
|
||||
export async function refreshAllLists(db: Db): Promise<void> {
|
||||
for (const list of repos.listIpLists(db)) {
|
||||
if (list.type === 'static') continue
|
||||
await refreshIpList(db, list.id)
|
||||
}
|
||||
await refreshAllHostnameRules(db)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
const rules = repos.listHostnameRules(db)
|
||||
const changedSets = new Set<string>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ const NAV_ITEMS = [
|
||||
},
|
||||
{
|
||||
to: '/rules',
|
||||
label: 'Правила',
|
||||
keywords: ['rules', 'правила', 'policy'],
|
||||
label: 'Наборы правил',
|
||||
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
|
||||
icon: ShieldIcon,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ const routeTitles: Record<string, string> = {
|
||||
'/': 'Панель управления',
|
||||
'/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 }]
|
||||
|
||||
@@ -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<MonitorMetric[]>(
|
||||
|
||||
@@ -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<PolicySet & { agent_ids: string[] }>(
|
||||
`/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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string[] | null>(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<PolicyRule>[] = useMemo(
|
||||
() => [
|
||||
{ accessorKey: 'priority', header: 'Prio' },
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.action === 'deny'
|
||||
? 'destructive-light'
|
||||
: 'success-light'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.action}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
header: 'Source',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.cidr ?? row.original.list_id ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
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() {
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Наборы правил</FrameTitle>
|
||||
<FrameDescription>
|
||||
Можно назначить несколько — мержатся при sync
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(setsQ.data?.items ?? []).map((s) => {
|
||||
const checked = assignedIds.includes(s.id)
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className="flex cursor-pointer items-center gap-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
setSelectedSets(
|
||||
v
|
||||
? [...assignedIds, s.id]
|
||||
: assignedIds.filter((x) => x !== s.id),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Link
|
||||
to="/rules/$setId"
|
||||
params={{ setId: s.id }}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
{!s.enabled ? (
|
||||
<Badge variant="secondary" size="xs">
|
||||
off
|
||||
</Badge>
|
||||
) : null}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-3"
|
||||
disabled={saveSets.isPending || selectedSets === null}
|
||||
onClick={() => saveSets.mutate()}
|
||||
>
|
||||
Сохранить наборы
|
||||
</Button>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Мгновенный IP override</FrameTitle>
|
||||
@@ -277,7 +312,10 @@ function AgentDetailPage() {
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Копировать правила</FrameTitle>
|
||||
<FrameTitle>Копировать наборы</FrameTitle>
|
||||
<FrameDescription>
|
||||
Копирует назначения наборов (+ overrides) с другого агента
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -308,20 +346,6 @@ function AgentDetailPage() {
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Правила агента</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<DataGrid
|
||||
table={rulesTable}
|
||||
recordCount={rulesQ.data?.items?.length ?? 0}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
@@ -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<SourceKind>('cidr')
|
||||
const [listId, setListId] = useState('')
|
||||
const [cidr, setCidr] = useState('')
|
||||
const [hostname, setHostname] = useState('')
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[] | null>(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<string, unknown> = {
|
||||
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<PolicyRule>[] = useMemo(
|
||||
() => [
|
||||
{ accessorKey: 'priority', header: 'Prio' },
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.action === 'deny'
|
||||
? 'destructive-light'
|
||||
: 'success-light'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.action}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
header: 'Источник',
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
if (r.hostname) {
|
||||
return (
|
||||
<span className="text-xs">
|
||||
DNS <span className="font-mono">{r.hostname}</span>
|
||||
{typeof r.resolved_count === 'number'
|
||||
? ` (${r.resolved_count} IP)`
|
||||
: ''}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (r.cidr) {
|
||||
return <span className="font-mono text-xs">{r.cidr}</span>
|
||||
}
|
||||
return (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
list:{r.list_id?.slice(0, 8)}…
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeRule.mutate(row.original.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[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 (
|
||||
<PageShell>
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (!setQ.data) {
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader title="Набор не найден" />
|
||||
<Button render={<Link to="/rules" />}>К списку</Button>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
const set = setQ.data
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title={set.name}
|
||||
description={set.description ?? 'Набор правил политики'}
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={set.enabled}
|
||||
onCheckedChange={(v) => patchSet.mutate({ enabled: v })}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{set.enabled ? 'Включён' : 'Выключен'}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" render={<Link to="/rules" />}>
|
||||
Назад
|
||||
</Button>
|
||||
<Button onClick={() => setRuleOpen(true)}>Правило</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<DetailPanel>
|
||||
<DetailPanel.Section
|
||||
title="Правила"
|
||||
description="Список IP, CIDR или DNS-имя (резолвится в A/AAAA)."
|
||||
>
|
||||
<Frame dense spacing="sm">
|
||||
<FramePanel className="p-0">
|
||||
<DataGrid table={table} recordCount={rules.length}>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DetailPanel.Section>
|
||||
|
||||
<DetailPanel.Section
|
||||
title="Назначено агентам"
|
||||
description="Агент может иметь несколько наборов — они мержатся при sync."
|
||||
>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Агенты</FrameTitle>
|
||||
<FrameDescription>
|
||||
Отметьте, каким агентам применять этот набор
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(agentsQ.data?.items ?? [])
|
||||
.filter((a) => a.status === 'approved')
|
||||
.map((a) => {
|
||||
const checked = assignedIds.includes(a.id)
|
||||
return (
|
||||
<label
|
||||
key={a.id}
|
||||
className="flex cursor-pointer items-center gap-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const base = assignedIds
|
||||
setSelectedAgents(
|
||||
v
|
||||
? [...base, a.id]
|
||||
: base.filter((id) => id !== a.id),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<span className="font-medium">{a.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{a.hostname ?? a.platform}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{(agentsQ.data?.items ?? []).filter((a) => a.status === 'approved')
|
||||
.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет approved-агентов
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-3"
|
||||
disabled={saveAgents.isPending || selectedAgents === null}
|
||||
onClick={() => saveAgents.mutate()}
|
||||
>
|
||||
Сохранить назначение
|
||||
</Button>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
<Sheet open={ruleOpen} onOpenChange={setRuleOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новое правило</SheetTitle>
|
||||
<SheetDescription>
|
||||
Один источник: список, CIDR или DNS-имя
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="prio">Priority</FieldLabel>
|
||||
<Input
|
||||
id="prio"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<Select
|
||||
value={action}
|
||||
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">deny</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
<Select
|
||||
value={source}
|
||||
onValueChange={(v) => setSource(v as SourceKind)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cidr">CIDR / IP</SelectItem>
|
||||
<SelectItem value="hostname">DNS-имя</SelectItem>
|
||||
<SelectItem value="list">IP-список</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{source === 'list' ? (
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel>Список</FieldLabel>
|
||||
<Select
|
||||
value={listId || null}
|
||||
onValueChange={(v) => setListId(v ?? '')}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Выберите список" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(listsQ.data?.items ?? []).map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'cidr' ? (
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel htmlFor="cidr">CIDR</FieldLabel>
|
||||
<Input
|
||||
id="cidr"
|
||||
value={cidr}
|
||||
onChange={(e) => setCidr(e.target.value)}
|
||||
placeholder="203.0.113.0/24"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'hostname' ? (
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel htmlFor="host">DNS-имя</FieldLabel>
|
||||
<Input
|
||||
id="host"
|
||||
value={hostname}
|
||||
onChange={(e) => setHostname(e.target.value)}
|
||||
placeholder="bad.example.com"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
<Button variant="outline" onClick={() => setRuleOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canCreate || createRule.isPending}
|
||||
onClick={() => createRule.mutate()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
+106
-136
@@ -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<Filter[]>([])
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/rules', {
|
||||
apiFetch<PolicySet>('/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<PolicyRule>[] = useMemo(
|
||||
const columns: ColumnDef<PolicySet>[] = useMemo(
|
||||
() => [
|
||||
{ accessorKey: 'priority', header: 'Prio' },
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: 'Action',
|
||||
accessorKey: 'name',
|
||||
header: 'Набор',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to="/rules/$setId"
|
||||
params={{ setId: row.original.id }}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.original.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'enabled',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.action === 'deny'
|
||||
? 'destructive-light'
|
||||
: 'success-light'
|
||||
}
|
||||
variant={row.original.enabled ? 'success-light' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.action}
|
||||
{row.original.enabled ? 'Включён' : 'Выключен'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'agent_id',
|
||||
header: 'Agent',
|
||||
accessorKey: 'rules_count',
|
||||
header: 'Правила',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.agent_id ?? 'tenant'}</span>
|
||||
<span className="tabular-nums">{row.original.rules_count ?? 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
header: 'List / CIDR',
|
||||
accessorKey: 'agents_count',
|
||||
header: 'Агенты',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.cidr ?? row.original.list_id ?? '—'}
|
||||
</span>
|
||||
<span className="tabular-nums">{row.original.agents_count ?? 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => remove.mutate(row.original.id)}
|
||||
>
|
||||
Удалить
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" variant="outline" render={<Link to="/rules/$setId" params={{ setId: row.original.id }} />}>
|
||||
Открыть
|
||||
</Button>
|
||||
{row.original.id !== 'set-shared-default' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => remove.mutate(row.original.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -151,15 +163,15 @@ function RulesPage() {
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Правила"
|
||||
description="Упорядоченные allow/deny по списку или CIDR"
|
||||
title="Наборы правил"
|
||||
description="Именованные наборы назначаются агентам (можно несколько). Источник правила: список, CIDR или DNS."
|
||||
actions={
|
||||
<Button onClick={() => setSheetOpen(true)}>Новое правило</Button>
|
||||
<Button onClick={() => setSheetOpen(true)}>Новый набор</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourcePage
|
||||
title="Правила"
|
||||
title="Наборы"
|
||||
hideHeader
|
||||
data={items}
|
||||
columns={columns}
|
||||
@@ -169,15 +181,15 @@ function RulesPage() {
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => 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: (
|
||||
<Button onClick={() => setSheetOpen(true)}>Новое правило</Button>
|
||||
<Button onClick={() => setSheetOpen(true)}>Новый набор</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -185,71 +197,29 @@ function RulesPage() {
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новое правило</SheetTitle>
|
||||
<SheetDescription>Priority + action + list scope</SheetDescription>
|
||||
<SheetTitle>Новый набор</SheetTitle>
|
||||
<SheetDescription>
|
||||
После создания добавьте правила и назначьте набор агентам.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4 sm:grid-cols-2">
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rule-priority">Priority</FieldLabel>
|
||||
<FieldLabel htmlFor="set-name">Имя</FieldLabel>
|
||||
<Input
|
||||
id="rule-priority"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
id="set-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Web deny"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<Select
|
||||
value={action}
|
||||
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">deny</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel>Список</FieldLabel>
|
||||
<Select
|
||||
value={listId || null}
|
||||
onValueChange={(v) => setListId(v ?? '')}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="IP list" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(listsQ.data?.items ?? []).map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel>Scope</FieldLabel>
|
||||
<Select
|
||||
value={agentId}
|
||||
onValueChange={(v) => {
|
||||
if (v) setAgentId(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tenant">Tenant default</SelectItem>
|
||||
{(agentsQ.data?.items ?? []).map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldLabel htmlFor="set-desc">Описание</FieldLabel>
|
||||
<Textarea
|
||||
id="set-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
@@ -257,7 +227,7 @@ function RulesPage() {
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!listId || create.isPending}
|
||||
disabled={!name.trim() || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
Создать
|
||||
|
||||
@@ -23,9 +23,12 @@
|
||||
|
||||
## Политика
|
||||
|
||||
- Именованные **наборы правил** (`policy_sets`); агенту назначается **M:N** через `agent_policy_sets`
|
||||
- Правило в наборе: ровно один источник — 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 и clone-from бампят `policy_generation`
|
||||
- Overrides, смена наборов и refresh DNS/lists бампят `policy_generation`
|
||||
|
||||
## Auth
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
-- Policy sets + DNS hostname rules (v2)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy_sets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
CHECK (enabled IN (0, 1)),
|
||||
CHECK (length(trim(name)) > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_policy_sets (
|
||||
agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
|
||||
set_id TEXT NOT NULL REFERENCES policy_sets (id) ON DELETE CASCADE,
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (agent_id, set_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_policy_sets_set ON agent_policy_sets (set_id);
|
||||
|
||||
-- Rebuild policy_rules with set_id + hostname (drop agent_id)
|
||||
CREATE TABLE IF NOT EXISTS policy_rules_v2 (
|
||||
id TEXT PRIMARY KEY,
|
||||
set_id TEXT NOT NULL REFERENCES policy_sets (id) ON DELETE CASCADE,
|
||||
priority INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
list_id TEXT REFERENCES ip_lists (id) ON DELETE CASCADE,
|
||||
cidr TEXT,
|
||||
hostname TEXT,
|
||||
comment TEXT,
|
||||
created_by_user_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
CHECK (action IN ('allow', 'deny')),
|
||||
CHECK (priority >= 1 AND priority <= 10000)
|
||||
);
|
||||
|
||||
-- Shared default set
|
||||
INSERT OR IGNORE INTO policy_sets (id, name, description, enabled)
|
||||
VALUES (
|
||||
'set-shared-default',
|
||||
'Общие',
|
||||
'Набор по умолчанию (бывшие tenant-правила). Назначается approved-агентам.',
|
||||
1
|
||||
);
|
||||
|
||||
-- Per-agent sets for agents that already have rules
|
||||
INSERT OR IGNORE INTO policy_sets (id, name, description, enabled)
|
||||
SELECT
|
||||
'set-agent-' || a.id,
|
||||
'Агент: ' || a.name,
|
||||
'Мигрировано из правил агента',
|
||||
1
|
||||
FROM agents a
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM policy_rules r WHERE r.agent_id = a.id
|
||||
);
|
||||
|
||||
-- Copy tenant rules → Общие
|
||||
INSERT INTO policy_rules_v2 (
|
||||
id, set_id, priority, action, list_id, cidr, hostname, comment,
|
||||
created_by_user_id, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
'set-shared-default',
|
||||
priority,
|
||||
action,
|
||||
list_id,
|
||||
cidr,
|
||||
NULL,
|
||||
comment,
|
||||
created_by_user_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM policy_rules
|
||||
WHERE agent_id IS NULL;
|
||||
|
||||
-- Copy agent rules → per-agent sets
|
||||
INSERT INTO policy_rules_v2 (
|
||||
id, set_id, priority, action, list_id, cidr, hostname, comment,
|
||||
created_by_user_id, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
'set-agent-' || agent_id,
|
||||
priority,
|
||||
action,
|
||||
list_id,
|
||||
cidr,
|
||||
NULL,
|
||||
comment,
|
||||
created_by_user_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM policy_rules
|
||||
WHERE agent_id IS NOT NULL;
|
||||
|
||||
DROP TABLE policy_rules;
|
||||
ALTER TABLE policy_rules_v2 RENAME TO policy_rules;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_policy_rules_set_priority
|
||||
ON policy_rules (set_id, priority);
|
||||
|
||||
-- Assign «Общие» to all approved agents
|
||||
INSERT OR IGNORE INTO agent_policy_sets (agent_id, set_id, sort)
|
||||
SELECT id, 'set-shared-default', 0
|
||||
FROM agents
|
||||
WHERE status = 'approved';
|
||||
|
||||
-- Assign per-agent migrated sets
|
||||
INSERT OR IGNORE INTO agent_policy_sets (agent_id, set_id, sort)
|
||||
SELECT
|
||||
substr(id, length('set-agent-') + 1),
|
||||
id,
|
||||
10
|
||||
FROM policy_sets
|
||||
WHERE id LIKE 'set-agent-%';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy_rule_resolved (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES policy_rules (id) ON DELETE CASCADE,
|
||||
cidr TEXT NOT NULL,
|
||||
resolved_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_policy_rule_resolved_rule_cidr
|
||||
ON policy_rule_resolved (rule_id, cidr);
|
||||
CREATE INDEX IF NOT EXISTS idx_policy_rule_resolved_rule
|
||||
ON policy_rule_resolved (rule_id);
|
||||
@@ -1,13 +1,17 @@
|
||||
import { eq, and, desc, isNull, sql } from 'drizzle-orm'
|
||||
import { eq, and, desc, asc, sql, count, inArray } from 'drizzle-orm'
|
||||
import type { Db } from '../client.js'
|
||||
import {
|
||||
agents,
|
||||
ipLists,
|
||||
ipListEntries,
|
||||
policySets,
|
||||
agentPolicySets,
|
||||
policyRules,
|
||||
policyRuleResolved,
|
||||
ipOverrides,
|
||||
agentStatsSamples,
|
||||
settings,
|
||||
SHARED_POLICY_SET_ID,
|
||||
} from '../schema.js'
|
||||
|
||||
export function listAgents(db: Db) {
|
||||
@@ -22,10 +26,7 @@ export function getAgentByTokenHash(db: Db, tokenHash: string) {
|
||||
return db.select().from(agents).where(eq(agents.tokenHash, tokenHash)).get()
|
||||
}
|
||||
|
||||
export function insertAgent(
|
||||
db: Db,
|
||||
row: typeof agents.$inferInsert,
|
||||
) {
|
||||
export function insertAgent(db: Db, row: typeof agents.$inferInsert) {
|
||||
db.insert(agents).values(row).run()
|
||||
return getAgent(db, row.id)
|
||||
}
|
||||
@@ -50,6 +51,24 @@ export function bumpAgentGeneration(db: Db, id: string) {
|
||||
.run()
|
||||
}
|
||||
|
||||
export function bumpAgentsForSet(db: Db, setId: string) {
|
||||
const rows = db
|
||||
.select({ agentId: agentPolicySets.agentId })
|
||||
.from(agentPolicySets)
|
||||
.where(eq(agentPolicySets.setId, setId))
|
||||
.all()
|
||||
for (const r of rows) bumpAgentGeneration(db, r.agentId)
|
||||
}
|
||||
|
||||
export function bumpAllApprovedAgents(db: Db) {
|
||||
const rows = db
|
||||
.select({ id: agents.id })
|
||||
.from(agents)
|
||||
.where(eq(agents.status, 'approved'))
|
||||
.all()
|
||||
for (const r of rows) bumpAgentGeneration(db, r.id)
|
||||
}
|
||||
|
||||
export function listIpLists(db: Db) {
|
||||
return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all()
|
||||
}
|
||||
@@ -102,24 +121,144 @@ export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export function listPolicyRules(db: Db, agentId?: string | null) {
|
||||
if (agentId === undefined) {
|
||||
return db.select().from(policyRules).orderBy(policyRules.priority).all()
|
||||
/* ── Policy sets ── */
|
||||
|
||||
export function listPolicySets(db: Db) {
|
||||
return db.select().from(policySets).orderBy(asc(policySets.name)).all()
|
||||
}
|
||||
|
||||
export function getPolicySet(db: Db, id: string) {
|
||||
return db.select().from(policySets).where(eq(policySets.id, id)).get()
|
||||
}
|
||||
|
||||
export function insertPolicySet(db: Db, row: typeof policySets.$inferInsert) {
|
||||
db.insert(policySets).values(row).run()
|
||||
return getPolicySet(db, row.id)
|
||||
}
|
||||
|
||||
export function updatePolicySet(
|
||||
db: Db,
|
||||
id: string,
|
||||
patch: Partial<typeof policySets.$inferInsert>,
|
||||
) {
|
||||
db.update(policySets)
|
||||
.set({ ...patch, updatedAt: new Date().toISOString() })
|
||||
.where(eq(policySets.id, id))
|
||||
.run()
|
||||
return getPolicySet(db, id)
|
||||
}
|
||||
|
||||
export function deletePolicySet(db: Db, id: string) {
|
||||
if (id === SHARED_POLICY_SET_ID) {
|
||||
throw new Error('cannot delete shared default set')
|
||||
}
|
||||
if (agentId === null) {
|
||||
db.delete(policySets).where(eq(policySets.id, id)).run()
|
||||
}
|
||||
|
||||
export function countRulesInSet(db: Db, setId: string): number {
|
||||
const row = db
|
||||
.select({ n: count() })
|
||||
.from(policyRules)
|
||||
.where(eq(policyRules.setId, setId))
|
||||
.get()
|
||||
return row?.n ?? 0
|
||||
}
|
||||
|
||||
export function countAgentsForSet(db: Db, setId: string): number {
|
||||
const row = db
|
||||
.select({ n: count() })
|
||||
.from(agentPolicySets)
|
||||
.where(eq(agentPolicySets.setId, setId))
|
||||
.get()
|
||||
return row?.n ?? 0
|
||||
}
|
||||
|
||||
export function listAgentIdsForSet(db: Db, setId: string): string[] {
|
||||
return db
|
||||
.select({ agentId: agentPolicySets.agentId })
|
||||
.from(agentPolicySets)
|
||||
.where(eq(agentPolicySets.setId, setId))
|
||||
.all()
|
||||
.map((r) => r.agentId)
|
||||
}
|
||||
|
||||
export function listSetsForAgent(db: Db, agentId: string) {
|
||||
return db
|
||||
.select({
|
||||
setId: agentPolicySets.setId,
|
||||
sort: agentPolicySets.sort,
|
||||
name: policySets.name,
|
||||
description: policySets.description,
|
||||
enabled: policySets.enabled,
|
||||
})
|
||||
.from(agentPolicySets)
|
||||
.innerJoin(policySets, eq(agentPolicySets.setId, policySets.id))
|
||||
.where(eq(agentPolicySets.agentId, agentId))
|
||||
.orderBy(asc(agentPolicySets.sort), asc(policySets.name))
|
||||
.all()
|
||||
}
|
||||
|
||||
/** Replace agent↔set assignments; set_ids order = sort. */
|
||||
export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
|
||||
db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
|
||||
setIds.forEach((setId, i) => {
|
||||
db.insert(agentPolicySets)
|
||||
.values({ agentId, setId, sort: i * 10 })
|
||||
.run()
|
||||
})
|
||||
bumpAgentGeneration(db, agentId)
|
||||
}
|
||||
|
||||
export function ensureSharedSetAssigned(db: Db, agentId: string) {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(agentPolicySets)
|
||||
.where(
|
||||
and(
|
||||
eq(agentPolicySets.agentId, agentId),
|
||||
eq(agentPolicySets.setId, SHARED_POLICY_SET_ID),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
if (existing) return
|
||||
db.insert(agentPolicySets)
|
||||
.values({ agentId, setId: SHARED_POLICY_SET_ID, sort: 0 })
|
||||
.run()
|
||||
}
|
||||
|
||||
/* ── Policy rules ── */
|
||||
|
||||
export function listPolicyRules(db: Db, setId?: string) {
|
||||
if (setId) {
|
||||
return db
|
||||
.select()
|
||||
.from(policyRules)
|
||||
.where(isNull(policyRules.agentId))
|
||||
.orderBy(policyRules.priority)
|
||||
.where(eq(policyRules.setId, setId))
|
||||
.orderBy(asc(policyRules.priority))
|
||||
.all()
|
||||
}
|
||||
return db
|
||||
return db.select().from(policyRules).orderBy(asc(policyRules.priority)).all()
|
||||
}
|
||||
|
||||
export function listPolicyRulesForAgent(db: Db, agentId: string) {
|
||||
const assignments = listSetsForAgent(db, agentId).filter((s) => s.enabled === 1)
|
||||
if (assignments.length === 0) return []
|
||||
|
||||
const setIds = assignments.map((a) => a.setId)
|
||||
const sortBySet = new Map(assignments.map((a) => [a.setId, a.sort]))
|
||||
|
||||
const rules = db
|
||||
.select()
|
||||
.from(policyRules)
|
||||
.where(eq(policyRules.agentId, agentId))
|
||||
.orderBy(policyRules.priority)
|
||||
.where(inArray(policyRules.setId, setIds))
|
||||
.all()
|
||||
|
||||
return rules
|
||||
.map((r) => ({
|
||||
...r,
|
||||
_setSort: sortBySet.get(r.setId) ?? 0,
|
||||
}))
|
||||
.sort((a, b) => a._setSort - b._setSort || a.priority - b.priority)
|
||||
}
|
||||
|
||||
export function getPolicyRule(db: Db, id: string) {
|
||||
@@ -138,6 +277,39 @@ export function deletePolicyRule(db: Db, id: string) {
|
||||
db.delete(policyRules).where(eq(policyRules.id, id)).run()
|
||||
}
|
||||
|
||||
export function listHostnameRules(db: Db) {
|
||||
return db
|
||||
.select()
|
||||
.from(policyRules)
|
||||
.where(sql`${policyRules.hostname} IS NOT NULL AND trim(${policyRules.hostname}) != ''`)
|
||||
.all()
|
||||
}
|
||||
|
||||
export function listResolvedForRule(db: Db, ruleId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(policyRuleResolved)
|
||||
.where(eq(policyRuleResolved.ruleId, ruleId))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function replaceResolvedForRule(db: Db, ruleId: string, cidrs: string[]) {
|
||||
db.delete(policyRuleResolved)
|
||||
.where(eq(policyRuleResolved.ruleId, ruleId))
|
||||
.run()
|
||||
const now = new Date().toISOString()
|
||||
for (const cidr of cidrs) {
|
||||
db.insert(policyRuleResolved)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
ruleId,
|
||||
cidr,
|
||||
resolvedAt: now,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
export function listOverrides(db: Db, agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
@@ -206,6 +378,7 @@ export function listSettings(db: Db) {
|
||||
return db.select().from(settings).all()
|
||||
}
|
||||
|
||||
/** Clone set assignments (+ optional overrides) and policy mode from source agent. */
|
||||
export function cloneRulesFrom(
|
||||
db: Db,
|
||||
sourceAgentId: string,
|
||||
@@ -216,23 +389,12 @@ export function cloneRulesFrom(
|
||||
const target = getAgent(db, targetAgentId)
|
||||
if (!source || !target) return null
|
||||
|
||||
db.delete(policyRules).where(eq(policyRules.agentId, targetAgentId)).run()
|
||||
const rules = listPolicyRules(db, sourceAgentId)
|
||||
for (const r of rules) {
|
||||
db.insert(policyRules)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
agentId: targetAgentId,
|
||||
priority: r.priority,
|
||||
action: r.action,
|
||||
listId: r.listId,
|
||||
cidr: r.cidr,
|
||||
comment: r.comment,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
const sourceSets = listSetsForAgent(db, sourceAgentId)
|
||||
setAgentPolicySets(
|
||||
db,
|
||||
targetAgentId,
|
||||
sourceSets.map((s) => s.setId),
|
||||
)
|
||||
|
||||
if (includeOverrides) {
|
||||
db.delete(ipOverrides).where(eq(ipOverrides.agentId, targetAgentId)).run()
|
||||
@@ -265,6 +427,8 @@ export const repos = {
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
bumpAgentGeneration,
|
||||
bumpAgentsForSet,
|
||||
bumpAllApprovedAgents,
|
||||
listIpLists,
|
||||
getIpList,
|
||||
insertIpList,
|
||||
@@ -272,10 +436,25 @@ export const repos = {
|
||||
deleteIpList,
|
||||
listIpListEntries,
|
||||
replaceIpListEntries,
|
||||
listPolicySets,
|
||||
getPolicySet,
|
||||
insertPolicySet,
|
||||
updatePolicySet,
|
||||
deletePolicySet,
|
||||
countRulesInSet,
|
||||
countAgentsForSet,
|
||||
listAgentIdsForSet,
|
||||
listSetsForAgent,
|
||||
setAgentPolicySets,
|
||||
ensureSharedSetAssigned,
|
||||
listPolicyRules,
|
||||
listPolicyRulesForAgent,
|
||||
getPolicyRule,
|
||||
insertPolicyRule,
|
||||
deletePolicyRule,
|
||||
listHostnameRules,
|
||||
listResolvedForRule,
|
||||
replaceResolvedForRule,
|
||||
listOverrides,
|
||||
insertOverride,
|
||||
deleteOverride,
|
||||
@@ -287,3 +466,5 @@ export const repos = {
|
||||
listSettings,
|
||||
cloneRulesFrom,
|
||||
}
|
||||
|
||||
export { SHARED_POLICY_SET_ID }
|
||||
|
||||
@@ -78,15 +78,49 @@ export const ipListEntries = sqliteTable(
|
||||
}),
|
||||
)
|
||||
|
||||
/** Named reusable policy sets (M:N with agents). */
|
||||
export const policySets = sqliteTable('policy_sets', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
enabled: integer('enabled').notNull().default(1),
|
||||
createdAt: text('created_at')
|
||||
.notNull()
|
||||
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||
updatedAt: text('updated_at')
|
||||
.notNull()
|
||||
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||
})
|
||||
|
||||
export const agentPolicySets = sqliteTable(
|
||||
'agent_policy_sets',
|
||||
{
|
||||
agentId: text('agent_id')
|
||||
.notNull()
|
||||
.references(() => agents.id, { onDelete: 'cascade' }),
|
||||
setId: text('set_id')
|
||||
.notNull()
|
||||
.references(() => policySets.id, { onDelete: 'cascade' }),
|
||||
sort: integer('sort').notNull().default(0),
|
||||
},
|
||||
(t) => ({
|
||||
pk: uniqueIndex('idx_agent_policy_sets_pk').on(t.agentId, t.setId),
|
||||
setIdx: index('idx_agent_policy_sets_set').on(t.setId),
|
||||
}),
|
||||
)
|
||||
|
||||
export const policyRules = sqliteTable(
|
||||
'policy_rules',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
agentId: text('agent_id').references(() => agents.id, { onDelete: 'cascade' }), // null = tenant default
|
||||
setId: text('set_id')
|
||||
.notNull()
|
||||
.references(() => policySets.id, { onDelete: 'cascade' }),
|
||||
priority: integer('priority').notNull(),
|
||||
action: text('action').notNull(), // allow | deny
|
||||
listId: text('list_id').references(() => ipLists.id, { onDelete: 'cascade' }),
|
||||
cidr: text('cidr'),
|
||||
hostname: text('hostname'),
|
||||
comment: text('comment'),
|
||||
createdByUserId: text('created_by_user_id'),
|
||||
createdAt: text('created_at')
|
||||
@@ -97,7 +131,26 @@ export const policyRules = sqliteTable(
|
||||
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||
},
|
||||
(t) => ({
|
||||
agentPriority: uniqueIndex('idx_policy_rules_agent_priority').on(t.agentId, t.priority),
|
||||
setPriority: uniqueIndex('idx_policy_rules_set_priority').on(t.setId, t.priority),
|
||||
}),
|
||||
)
|
||||
|
||||
/** DNS resolve cache for hostname rules. */
|
||||
export const policyRuleResolved = sqliteTable(
|
||||
'policy_rule_resolved',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
ruleId: text('rule_id')
|
||||
.notNull()
|
||||
.references(() => policyRules.id, { onDelete: 'cascade' }),
|
||||
cidr: text('cidr').notNull(),
|
||||
resolvedAt: text('resolved_at')
|
||||
.notNull()
|
||||
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||
},
|
||||
(t) => ({
|
||||
ruleCidr: uniqueIndex('idx_policy_rule_resolved_rule_cidr').on(t.ruleId, t.cidr),
|
||||
ruleIdx: index('idx_policy_rule_resolved_rule').on(t.ruleId),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -141,12 +194,17 @@ export const agentStatsSamples = sqliteTable(
|
||||
}),
|
||||
)
|
||||
|
||||
export const SHARED_POLICY_SET_ID = 'set-shared-default'
|
||||
|
||||
export const schema = {
|
||||
settings,
|
||||
agents,
|
||||
ipLists,
|
||||
ipListEntries,
|
||||
policySets,
|
||||
agentPolicySets,
|
||||
policyRules,
|
||||
policyRuleResolved,
|
||||
ipOverrides,
|
||||
agentStatsSamples,
|
||||
}
|
||||
|
||||
@@ -51,16 +51,29 @@ export const ipListSchema = z.object({
|
||||
|
||||
export const policyRuleSchema = z.object({
|
||||
id: z.string(),
|
||||
agent_id: z.string().nullable().optional(),
|
||||
set_id: z.string(),
|
||||
priority: z.number().int(),
|
||||
action: policyActionSchema,
|
||||
list_id: z.string().nullable().optional(),
|
||||
cidr: z.string().nullable().optional(),
|
||||
hostname: z.string().nullable().optional(),
|
||||
resolved_count: z.number().int().optional(),
|
||||
comment: z.string().nullable().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const policySetSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
enabled: z.boolean(),
|
||||
rules_count: z.number().int().optional(),
|
||||
agents_count: z.number().int().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const ipOverrideSchema = z.object({
|
||||
id: z.string(),
|
||||
agent_id: z.string(),
|
||||
@@ -77,13 +90,42 @@ export const createIpListBodySchema = z.object({
|
||||
entries: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const createPolicyRuleBodySchema = z.object({
|
||||
agent_id: z.string().nullable().optional(),
|
||||
priority: z.number().int().min(1).max(10000),
|
||||
action: policyActionSchema,
|
||||
list_id: z.string().nullable().optional(),
|
||||
cidr: z.string().nullable().optional(),
|
||||
comment: z.string().nullable().optional(),
|
||||
export const createPolicySetBodySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().nullable().optional(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
})
|
||||
|
||||
export const patchPolicySetBodySchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const createPolicyRuleBodySchema = z
|
||||
.object({
|
||||
set_id: z.string().min(1),
|
||||
priority: z.number().int().min(1).max(10000),
|
||||
action: policyActionSchema,
|
||||
list_id: z.string().nullable().optional(),
|
||||
cidr: z.string().nullable().optional(),
|
||||
hostname: z.string().nullable().optional(),
|
||||
comment: z.string().nullable().optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
const sources = [v.list_id, v.cidr, v.hostname].filter(
|
||||
(x) => typeof x === 'string' && x.trim().length > 0,
|
||||
)
|
||||
if (sources.length !== 1) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Укажите ровно один источник: list_id, cidr или hostname',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const putAgentPolicySetsBodySchema = z.object({
|
||||
set_ids: z.array(z.string()),
|
||||
})
|
||||
|
||||
export const createOverrideBodySchema = z.object({
|
||||
@@ -142,6 +184,7 @@ export const dashboardStatsSchema = z.object({
|
||||
export type Agent = z.infer<typeof agentSchema>
|
||||
export type IpList = z.infer<typeof ipListSchema>
|
||||
export type PolicyRule = z.infer<typeof policyRuleSchema>
|
||||
export type PolicySet = z.infer<typeof policySetSchema>
|
||||
export type IpOverride = z.infer<typeof ipOverrideSchema>
|
||||
export type AgentPolicy = z.infer<typeof agentPolicySchema>
|
||||
export type DashboardStats = z.infer<typeof dashboardStatsSchema>
|
||||
|
||||
Reference in New Issue
Block a user