Files
EvoFirewall/apps/api/src/routes/port-acl.ts
T
Denozordec 7a3f1fad25 perf(api): ретенция статистики, устранение N+1 и пагинация списков
- retention: ежедневный cron (03:17) + запуск на старте удаляет сырые
  agent_stats_samples старше STATS_RETENTION_DAYS (по умолчанию 30);
  lifetime-итоги на агенте и агрегаты сохраняются
- N+1 на поллинг-пути агента: evaluateAgentPolicy теперь делает по одному
  батч-запросу на записи списков / резолвы hostname / имена списков вместо
  запроса на каждое правило; mapPolicyRules и port-rules GET — аналогично
- пагинация: GET /agents, /lists, /rules, /install-links принимают
  limit/offset и возвращают total; без параметров — прежнее поведение
- openapi.yaml: approve-bulk, PUT /policy-sets/{id}/agents, параметры
  пагинации (redocly lint OK); тесты (47 passed)
2026-09-20 19:20:30 +07:00

370 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FastifyPluginAsync } from 'fastify'
import { createHash } from 'node:crypto'
import { repos } from '@evofw/db'
import {
createAgentPortRuleBodySchema,
importAgentPortRulesBodySchema,
updateAgentPortRuleBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
function mapPortRule(
row: NonNullable<ReturnType<typeof repos.getAgentPortRule>>,
listName?: string | null,
) {
return {
id: row.id,
agent_id: row.agentId,
action: row.action,
protocol: row.protocol,
port_start: row.portStart,
port_end: row.portEnd,
src_kind: row.srcKind,
src_cidr: row.srcCidr,
list_id: row.listId,
list_name: listName ?? null,
enabled: row.enabled === 1,
comment: row.comment,
priority: row.priority,
created_at: row.createdAt,
updated_at: row.updatedAt,
}
}
function validateSrc(
srcKind: string,
srcCidr: string | null | undefined,
listId: string | null | undefined,
db: Parameters<typeof repos.getIpList>[0],
) {
if (srcKind === 'cidr' && !srcCidr?.trim()) {
throw new AppError('VALIDATION_ERROR', 'src_cidr required', 400)
}
if (srcKind === 'list') {
if (!listId?.trim()) {
throw new AppError('VALIDATION_ERROR', 'list_id required', 400)
}
if (!repos.getIpList(db, listId)) {
throw new AppError('NOT_FOUND', 'IP list not found', 404)
}
}
}
export const portAclRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get<{ Params: { id: string } }>(
'/agents/:id/port-rules',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const rows = repos.listAgentPortRules(app.db, agent.id)
const listNames = repos.mapIpListNames(
app.db,
rows.map((r) => r.listId).filter((id): id is string => Boolean(id)),
)
const items = rows.map((row) =>
mapPortRule(
row,
row.listId ? (listNames.get(row.listId) ?? null) : null,
),
)
return { items }
},
)
app.post<{ Params: { id: string } }>(
'/agents/:id/port-rules',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
if (agent.platform !== 'linux') {
throw new AppError(
'VALIDATION_ERROR',
'Port ACL is only supported on Linux agents',
400,
)
}
const body = createAgentPortRuleBodySchema.parse(req.body)
const portEnd = body.port_end ?? body.port_start
const srcKind = body.src_kind
const srcCidr = srcKind === 'cidr' ? body.src_cidr!.trim() : null
const listId = srcKind === 'list' ? body.list_id! : null
validateSrc(srcKind, srcCidr, listId, app.db)
const now = new Date().toISOString()
const row = repos.insertAgentPortRule(app.db, {
id: crypto.randomUUID(),
agentId: agent.id,
action: body.action,
protocol: body.protocol,
portStart: body.port_start,
portEnd,
srcKind,
srcCidr,
listId,
enabled: body.enabled === false ? 0 : 1,
comment: body.comment ?? null,
priority: body.priority ?? 100,
createdAt: now,
updatedAt: now,
})
repos.bumpAgentGeneration(app.db, agent.id)
auditMutation(app, config, req, {
action: 'port_rule.create',
targetType: 'app_resource',
targetId: row!.id,
summary: `Port ACL ${body.action} ${body.protocol}/${body.port_start} для ${agent.name}`,
details: {
agent_id: agent.id,
rule_id: row!.id,
action: body.action,
protocol: body.protocol,
port_start: body.port_start,
port_end: portEnd,
},
})
const listName = row!.listId
? repos.getIpList(app.db, row!.listId)?.name
: null
return mapPortRule(row!, listName)
},
)
app.patch<{ Params: { id: string; ruleId: string } }>(
'/agents/:id/port-rules/:ruleId',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const existing = repos.getAgentPortRule(app.db, req.params.ruleId)
if (!existing || existing.agentId !== agent.id) {
throw new AppError('NOT_FOUND', 'Port rule not found', 404)
}
const body = updateAgentPortRuleBodySchema.parse(req.body)
const nextSrcKind = body.src_kind ?? existing.srcKind
const nextSrcCidr =
body.src_cidr !== undefined
? body.src_cidr
: existing.srcCidr
const nextListId =
body.list_id !== undefined ? body.list_id : existing.listId
validateSrc(nextSrcKind, nextSrcCidr, nextListId, app.db)
const portStart = body.port_start ?? existing.portStart
const portEnd = body.port_end ?? existing.portEnd
if (portEnd < portStart) {
throw new AppError(
'VALIDATION_ERROR',
'port_end must be >= port_start',
400,
)
}
const row = repos.updateAgentPortRule(app.db, existing.id, {
...(body.action !== undefined ? { action: body.action } : {}),
...(body.protocol !== undefined ? { protocol: body.protocol } : {}),
portStart,
portEnd,
srcKind: nextSrcKind,
srcCidr: nextSrcKind === 'cidr' ? nextSrcCidr : null,
listId: nextSrcKind === 'list' ? nextListId : null,
...(body.enabled !== undefined
? { enabled: body.enabled ? 1 : 0 }
: {}),
...(body.comment !== undefined ? { comment: body.comment } : {}),
...(body.priority !== undefined ? { priority: body.priority } : {}),
})
repos.bumpAgentGeneration(app.db, agent.id)
auditMutation(app, config, req, {
action: 'port_rule.update',
targetType: 'app_resource',
targetId: existing.id,
summary: `Port ACL обновлён у ${agent.name}`,
details: { agent_id: agent.id, rule_id: existing.id },
})
const listName = row!.listId
? repos.getIpList(app.db, row!.listId)?.name
: null
return mapPortRule(row!, listName)
},
)
app.delete<{ Params: { id: string; ruleId: string } }>(
'/agents/:id/port-rules/:ruleId',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const existing = repos.getAgentPortRule(app.db, req.params.ruleId)
if (!existing || existing.agentId !== agent.id) {
throw new AppError('NOT_FOUND', 'Port rule not found', 404)
}
repos.deleteAgentPortRule(app.db, existing.id)
repos.bumpAgentGeneration(app.db, agent.id)
auditMutation(app, config, req, {
action: 'port_rule.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: existing.id,
summary: `Port ACL удалён у ${agent.name}`,
details: { agent_id: agent.id, rule_id: existing.id },
})
return { ok: true }
},
)
app.post<{ Params: { id: string } }>(
'/agents/:id/port-rules/import',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
if (agent.platform !== 'linux') {
throw new AppError(
'VALIDATION_ERROR',
'Port ACL is only supported on Linux agents',
400,
)
}
const body = importAgentPortRulesBodySchema.parse(req.body)
const now = new Date().toISOString()
const created: ReturnType<typeof mapPortRule>[] = []
if (body.from === 'list') {
const list = repos.getIpList(app.db, body.list_id!)
if (!list) throw new AppError('NOT_FOUND', 'IP list not found', 404)
for (const p of body.ports) {
const portEnd = p.port_end ?? p.port_start
const row = repos.insertAgentPortRule(app.db, {
id: crypto.randomUUID(),
agentId: agent.id,
action: body.action,
protocol: body.protocol,
portStart: p.port_start,
portEnd,
srcKind: 'list',
srcCidr: null,
listId: list.id,
enabled: body.enabled === false ? 0 : 1,
comment: body.comment ?? `import list:${list.name}`,
priority: 100,
createdAt: now,
updatedAt: now,
})
created.push(mapPortRule(row!, list.name))
}
} else {
const set = repos.getPolicySet(app.db, body.set_id!)
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
const rules = repos.listPolicyRules(app.db, set.id)
const listIds = new Set<string>()
const cidrs = new Set<string>()
for (const r of rules) {
if (r.listId) listIds.add(r.listId)
if (r.cidr?.trim()) cidrs.add(r.cidr.trim())
}
if (!listIds.size && !cidrs.size) {
throw new AppError(
'VALIDATION_ERROR',
'Policy set has no list/cidr sources to import',
400,
)
}
for (const p of body.ports) {
const portEnd = p.port_end ?? p.port_start
for (const listId of listIds) {
const list = repos.getIpList(app.db, listId)
const row = repos.insertAgentPortRule(app.db, {
id: crypto.randomUUID(),
agentId: agent.id,
action: body.action,
protocol: body.protocol,
portStart: p.port_start,
portEnd,
srcKind: 'list',
srcCidr: null,
listId,
enabled: body.enabled === false ? 0 : 1,
comment:
body.comment ?? `import set:${set.name} list:${list?.name ?? listId}`,
priority: 100,
createdAt: now,
updatedAt: now,
})
created.push(mapPortRule(row!, list?.name ?? null))
}
for (const cidr of cidrs) {
const row = repos.insertAgentPortRule(app.db, {
id: crypto.randomUUID(),
agentId: agent.id,
action: body.action,
protocol: body.protocol,
portStart: p.port_start,
portEnd,
srcKind: 'cidr',
srcCidr: cidr,
listId: null,
enabled: body.enabled === false ? 0 : 1,
comment: body.comment ?? `import set:${set.name} cidr:${cidr}`,
priority: 100,
createdAt: now,
updatedAt: now,
})
created.push(mapPortRule(row!, null))
}
}
}
repos.bumpAgentGeneration(app.db, agent.id)
auditMutation(app, config, req, {
action: 'port_rule.import',
targetType: 'app_resource',
targetId: agent.id,
summary: `Импорт ${created.length} Port ACL для ${agent.name}`,
details: {
agent_id: agent.id,
from: body.from,
count: created.length,
},
})
return { items: created }
},
)
app.get<{ Params: { id: string } }>(
'/agents/:id/host-firewall',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const snap = repos.getHostFirewallSnapshot(app.db, agent.id)
if (!snap) {
return {
collected_at: null,
raw_digest: null,
rules: [],
listeners: [],
}
}
let payload: { rules?: unknown[]; listeners?: unknown[] } = {}
try {
payload = JSON.parse(snap.payloadJson) as typeof payload
} catch {
payload = {}
}
return {
collected_at: snap.collectedAt,
raw_digest: snap.rawDigest,
rules: payload.rules ?? [],
listeners: payload.listeners ?? [],
}
},
)
}
export function digestHostFirewallPayload(json: string): string {
return createHash('sha256').update(json).digest('hex').slice(0, 16)
}