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>, 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[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[] = [] 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() const cidrs = new Set() 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) }