feat(policy): именованные наборы правил с DNS и M:N привязкой к агентам
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m44s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

Правила живут в policy_sets; evaluate мержит назначенные наборы; источник list|CIDR|hostname с кэшем DNS.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 22:11:03 +07:00
co-authored by Cursor
parent cad05659b1
commit 0bed4367c7
18 changed files with 1524 additions and 312 deletions
+214 -44
View File
@@ -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 }
})
+3
View File
@@ -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)
}
+19 -9
View File
@@ -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)
}
}