- 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)
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
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)
|
||
}
|