API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP. Co-authored-by: Cursor <cursoragent@cursor.com>
414 lines
12 KiB
TypeScript
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 }
|
|
})
|
|
}
|