feat: реализовать EvoFirewall V1 control plane
API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
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 }
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user