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>) { 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 = {} 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 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 } }) }