feat(api, web): implement port ACL and host firewall snapshot features
- Added support for managing desired L4 port ACL rules for Linux agents, allowing for open/close actions on specified ports. - Introduced a new endpoint for CRUD operations on port rules, enhancing the API's capabilities for agent management. - Implemented functionality to collect and report host firewall snapshots, capturing observed rules and listeners for better monitoring. - Updated the agent detail view to include tabs for managing port ACLs and viewing host firewall data, improving user experience. - Enhanced documentation to reflect the new features and API changes, ensuring clarity for users and developers. These changes significantly improve the management and visibility of firewall rules and port access control for agents.
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
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 items = repos.listAgentPortRules(app.db, agent.id).map((row) => {
|
||||
const listName = row.listId
|
||||
? repos.getIpList(app.db, row.listId)?.name
|
||||
: null
|
||||
return mapPortRule(row, listName)
|
||||
})
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user