Files
EvoFirewall/apps/api/src/routes/control.ts
T
DenozordecandCursor ebadf70e2b
Build and Push EvoFirewall Docker Image / build-and-push (push) Failing after 25s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
feat: реализовать EvoFirewall V1 control plane
API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP.

Co-authored-by: Cursor <[email protected]>
2026-07-20 19:50:54 +07:00

414 lines
12 KiB
TypeScript

import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import {
createOverrideBodySchema,
createIpListBodySchema,
createPolicyRuleBodySchema,
patchAgentBodySchema,
cloneFromBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import { refreshIpList } from '../services/lists/refresh.js'
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
import type { AppConfig } from '../config.js'
function mapAgent(a: NonNullable<ReturnType<typeof repos.getAgent>>) {
return {
id: a.id,
name: a.name,
hostname: a.hostname,
platform: a.platform,
token_prefix: a.tokenPrefix,
status: a.status,
policy_mode: a.policyMode,
policy_generation: a.policyGeneration,
last_seen_at: a.lastSeenAt,
last_seen_ip: a.lastSeenIp,
last_apply_at: a.lastApplyAt,
last_apply_status: a.lastApplyStatus,
last_apply_error: a.lastApplyError,
last_apply_prefix_count: a.lastApplyPrefixCount,
last_apply_packets_dropped: a.lastApplyPacketsDropped,
last_apply_packets_accepted: a.lastApplyPacketsAccepted,
last_apply_kernel_method: a.lastApplyKernelMethod,
client_version: a.clientVersion,
created_at: a.createdAt,
approved_at: a.approvedAt,
revoked_at: a.revokedAt,
}
}
export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get('/dashboard', async () => {
const all = repos.listAgents(app.db)
const now = Date.now()
const online = all.filter((a) => {
if (!a.lastSeenAt || a.status !== 'approved') return false
return now - Date.parse(a.lastSeenAt) < 5 * 60_000
})
return {
agents_total: all.length,
agents_approved: all.filter((a) => a.status === 'approved').length,
agents_online: online.length,
agents_pending: all.filter((a) => a.status === 'pending').length,
packets_dropped: all.reduce(
(s, a) => s + (a.lastApplyPacketsDropped ?? 0),
0,
),
packets_accepted: all.reduce(
(s, a) => s + (a.lastApplyPacketsAccepted ?? 0),
0,
),
lists_total: repos.listIpLists(app.db).length,
}
})
app.get('/install-context', async () => {
const seed =
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
return {
suggested_cp_url: config.publicBaseUrl,
enroll_seed: seed,
install_sh_url: `${config.publicBaseUrl}/v1/agent/install.sh`,
mikrotik_url: `${config.publicBaseUrl}/v1/agent/mikrotik-install.rsc`,
sync_interval_sec: Number(
repos.getSetting(app.db, 'agent_sync_interval_sec') || '60',
),
}
})
// Agents
app.get('/agents', async () => ({
items: repos.listAgents(app.db).map(mapAgent),
}))
app.get<{ Params: { id: string } }>('/agents/:id', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
return mapAgent(a)
})
app.get<{ Params: { id: string } }>('/agents/:id/preview', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const policy = evaluateAgentPolicy(app.db, a.id)
return {
...policy,
deny_cidrs: policy.denyCidrs,
allow_cidrs: policy.allowCidrs,
policy_mode: policy.policyMode,
sync_interval_sec: policy.syncIntervalSec,
}
})
app.patch<{ Params: { id: string } }>('/agents/:id', async (req) => {
const body = patchAgentBodySchema.parse(req.body)
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const updated = repos.updateAgent(app.db, a.id, {
name: body.name,
policyMode: body.policy_mode,
settingsJson: body.settings
? JSON.stringify(body.settings)
: undefined,
policyGeneration:
body.policy_mode && body.policy_mode !== a.policyMode
? a.policyGeneration + 1
: a.policyGeneration,
})
return mapAgent(updated!)
})
app.post<{ Params: { id: string } }>('/agents/:id/approve', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const updated = repos.updateAgent(app.db, a.id, {
status: 'approved',
approvedAt: new Date().toISOString(),
})
return mapAgent(updated!)
})
app.post<{ Params: { id: string } }>('/agents/:id/revoke', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const updated = repos.updateAgent(app.db, a.id, {
status: 'revoked',
revokedAt: new Date().toISOString(),
})
return mapAgent(updated!)
})
app.delete<{ Params: { id: string } }>('/agents/:id', async (req) => {
repos.deleteAgent(app.db, req.params.id)
return { ok: true }
})
app.post<{ Params: { id: string; sourceId: string } }>(
'/agents/:id/clone-from/:sourceId',
async (req) => {
const body = cloneFromBodySchema.parse(req.body ?? {})
const updated = repos.cloneRulesFrom(
app.db,
req.params.sourceId,
req.params.id,
body.include_overrides ?? false,
)
if (!updated) throw new AppError('NOT_FOUND', 'Agent not found', 404)
return mapAgent(updated)
},
)
// Overrides
app.get<{ Params: { id: string } }>(
'/agents/:id/overrides',
async (req) => ({
items: repos.listOverrides(app.db, req.params.id).map((o) => ({
id: o.id,
agent_id: o.agentId,
cidr: o.cidr,
action: o.action,
comment: o.comment,
created_at: o.createdAt,
})),
}),
)
app.post<{ Params: { id: string } }>(
'/agents/:id/overrides',
async (req) => {
const body = createOverrideBodySchema.parse(req.body)
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const row = repos.insertOverride(app.db, {
id: crypto.randomUUID(),
agentId: a.id,
cidr: body.cidr,
action: body.action,
comment: body.comment ?? null,
createdByUserId: req.authUser?.id,
createdAt: new Date().toISOString(),
})
repos.bumpAgentGeneration(app.db, a.id)
return {
id: row!.id,
agent_id: row!.agentId,
cidr: row!.cidr,
action: row!.action,
comment: row!.comment,
created_at: row!.createdAt,
}
},
)
app.delete<{ Params: { id: string; overrideId: string } }>(
'/agents/:id/overrides/:overrideId',
async (req) => {
repos.deleteOverride(app.db, req.params.overrideId)
repos.bumpAgentGeneration(app.db, req.params.id)
return { ok: true }
},
)
// Lists
app.get('/lists', async () => {
const items = repos.listIpLists(app.db).map((l) => ({
id: l.id,
name: l.name,
type: l.type,
config_json: l.configJson,
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
entry_count: repos.listIpListEntries(app.db, l.id).length,
created_at: l.createdAt,
updated_at: l.updatedAt,
}))
return { items }
})
app.post('/lists', async (req) => {
const body = createIpListBodySchema.parse(req.body)
const id = crypto.randomUUID()
const list = repos.insertIpList(app.db, {
id,
name: body.name,
type: body.type,
configJson: JSON.stringify(body.config ?? {}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
if (body.entries?.length) {
repos.replaceIpListEntries(app.db, id, body.entries)
}
if (body.type !== 'static') {
await refreshIpList(app.db, id)
}
return {
id: list!.id,
name: list!.name,
type: list!.type,
config_json: list!.configJson,
created_at: list!.createdAt,
updated_at: list!.updatedAt,
}
})
app.get<{ Params: { id: string } }>('/lists/:id', async (req) => {
const l = repos.getIpList(app.db, req.params.id)
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
return {
id: l.id,
name: l.name,
type: l.type,
config_json: l.configJson,
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
entries: repos.listIpListEntries(app.db, l.id).map((e) => e.cidr),
created_at: l.createdAt,
updated_at: l.updatedAt,
}
})
app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => {
await refreshIpList(app.db, req.params.id)
const l = repos.getIpList(app.db, req.params.id)
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
return {
id: l.id,
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
entry_count: repos.listIpListEntries(app.db, l.id).length,
}
})
app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => {
repos.deleteIpList(app.db, req.params.id)
return { ok: true }
})
// Rules
app.get<{ Querystring: { agent_id?: string } }>('/rules', async (req) => {
const agentId =
req.query.agent_id === 'tenant' || req.query.agent_id === ''
? null
: req.query.agent_id
const items = (
agentId === undefined
? repos.listPolicyRules(app.db)
: repos.listPolicyRules(app.db, agentId)
).map((r) => ({
id: r.id,
agent_id: r.agentId,
priority: r.priority,
action: r.action,
list_id: r.listId,
cidr: r.cidr,
comment: r.comment,
created_at: r.createdAt,
updated_at: r.updatedAt,
}))
return { items }
})
app.post('/rules', async (req) => {
const body = createPolicyRuleBodySchema.parse(req.body)
if (!body.list_id && !body.cidr) {
throw new AppError('VALIDATION_ERROR', 'list_id or cidr required')
}
const row = repos.insertPolicyRule(app.db, {
id: crypto.randomUUID(),
agentId: body.agent_id ?? null,
priority: body.priority,
action: body.action,
listId: body.list_id ?? null,
cidr: body.cidr ?? null,
comment: body.comment ?? null,
createdByUserId: req.authUser?.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
if (body.agent_id) repos.bumpAgentGeneration(app.db, body.agent_id)
else {
for (const a of repos.listAgents(app.db)) {
if (a.status === 'approved') repos.bumpAgentGeneration(app.db, a.id)
}
}
return {
id: row!.id,
agent_id: row!.agentId,
priority: row!.priority,
action: row!.action,
list_id: row!.listId,
cidr: row!.cidr,
comment: row!.comment,
created_at: row!.createdAt,
updated_at: row!.updatedAt,
}
})
app.delete<{ Params: { id: string } }>('/rules/:id', async (req) => {
const rule = repos.getPolicyRule(app.db, req.params.id)
repos.deletePolicyRule(app.db, req.params.id)
if (rule?.agentId) repos.bumpAgentGeneration(app.db, rule.agentId)
return { ok: true }
})
// Stats
app.get<{ Params: { id: string } }>('/agents/:id/stats', async (req) => ({
items: repos.listStatsSamples(app.db, req.params.id).map((s) => ({
id: s.id,
agent_id: s.agentId,
packets_dropped: s.packetsDropped,
packets_accepted: s.packetsAccepted,
prefix_count: s.prefixCount,
kernel_method: s.kernelMethod,
recorded_at: s.recordedAt,
})),
}))
app.get('/stats/recent', async () => ({
items: repos.listRecentStats(app.db).map((s) => ({
id: s.id,
agent_id: s.agentId,
packets_dropped: s.packetsDropped,
packets_accepted: s.packetsAccepted,
prefix_count: s.prefixCount,
kernel_method: s.kernelMethod,
recorded_at: s.recordedAt,
})),
}))
// Settings
app.get('/settings', async () => {
const rows = repos.listSettings(app.db)
const map: Record<string, string> = {}
for (const r of rows) {
if (r.key === 'evobgp_api_token' && r.value) {
map[r.key] = '********'
} else {
map[r.key] = r.value
}
}
if (!map.enroll_seed) map.enroll_seed = config.enrollSeed
return map
})
app.put('/settings', async (req) => {
const body = req.body as Record<string, string>
for (const [k, v] of Object.entries(body)) {
if (typeof v !== 'string') continue
if (k === 'evobgp_api_token' && v === '********') continue
repos.setSetting(app.db, k, v)
}
return { ok: true }
})
}