feat(policy): именованные наборы правил с DNS и M:N привязкой к агентам
Правила живут в policy_sets; evaluate мержит назначенные наборы; источник list|CIDR|hostname с кэшем DNS. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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