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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user