import type { FastifyPluginAsync } from 'fastify' import { repos } from '@evofw/db' import { createOverrideBodySchema, createIpListBodySchema, createPolicyRuleBodySchema, createPolicySetBodySchema, createInstallLinkBodySchema, patchPolicySetBodySchema, patchPolicyRuleBodySchema, reorderPolicyRulesBodySchema, putAgentPolicySetsBodySchema, patchAgentBodySchema, cloneFromBodySchema, listEntriesBodySchema, deleteListEntryBodySchema, isManualListType, } from '@evofw/shared' import { AppError } from '../plugins/error-handler.js' import { refreshIpList } from '../services/lists/refresh.js' import { addListEntries, deleteListEntry, mapListDetail, } from '../services/lists/entries.js' import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js' import { resolveAndStoreHostnameRule, resolveHostnameToCidrs, } from '../services/policy/resolve-hostname.js' import { mapInstallLink, randomToken, buildInstallUrls, } from '../services/install-links.js' import { hashToken } from '../plugins/auth.js' import type { AppConfig } from '../config.js' import { auditMutation } from '../services/audit.js' function mapAgent( a: NonNullable>, opts?: { installCurl?: string | null; installLinkId?: string | null }, ) { const defaultAction = a.defaultAction === 'drop' ? ('drop' as const) : ('accept' as const) return { id: a.id, name: a.name, hostname: a.hostname, platform: a.platform, token_prefix: a.tokenPrefix, status: a.status, default_action: defaultAction, policy_mode: defaultAction === 'drop' ? ('whitelist' as const) : ('blacklist' as const), 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, install_curl: opts?.installCurl ?? null, install_link_id: opts?.installLinkId ?? null, } } function mapPolicySet( s: NonNullable>, db: Parameters[0], ) { return { id: s.id, name: s.name, description: s.description, enabled: s.enabled === 1, rules_count: repos.countRulesInSet(db, s.id), agents_count: repos.countAgentsForSet(db, s.id), created_at: s.createdAt, updated_at: s.updatedAt, } } function mapPolicyRule( r: NonNullable>, db: Parameters[0], ) { return { id: r.id, set_id: r.setId, priority: r.priority, action: r.action, enabled: r.enabled !== 0, list_id: r.listId, cidr: r.cidr, hostname: r.hostname, resolved_count: r.hostname ? repos.listResolvedForRule(db, r.id).length : undefined, comment: r.comment, created_at: r.createdAt, updated_at: r.updatedAt, } } 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', ), } }) // Install short-links app.get('/install-links', async () => ({ items: repos .listInstallLinks(app.db) .map((row) => mapInstallLink(row, config.publicBaseUrl)), })) app.post('/install-links', async (req, reply) => { const body = createInstallLinkBodySchema.parse(req.body) const linkId = randomToken(10) const slug = randomToken(16) if ( repos.getInstallLink(app.db, linkId) || repos.getInstallLinkBySlug(app.db, slug) ) { throw new AppError('CONFLICT', 'Retry create (id collision)', 409) } const agentId = crypto.randomUUID() const inviteToken = `invite:${agentId}` const now = new Date().toISOString() const name = body.name.trim() const platform = body.platform ?? 'linux' app.sqlite.transaction(() => { repos.insertAgent(app.db, { id: agentId, name, hostname: null, platform, tokenPrefix: inviteToken.slice(0, 12), tokenHash: hashToken(inviteToken), status: 'invited', defaultAction: 'accept', policyGeneration: 1, clientVersion: null, settingsJson: '{}', createdAt: now, }) repos.insertInstallLink(app.db, { id: linkId, slug, clientName: name, platform, agentId, createdAt: now, useCount: 0, }) })() const row = repos.getInstallLink(app.db, linkId)! auditMutation(app, config, req, { action: 'agent.create', targetType: 'app_resource', targetId: agentId, summary: `Создан агент (invite): ${name}`, details: { agent_id: agentId, platform, install_link_id: linkId }, }) return reply.code(201).send(mapInstallLink(row, config.publicBaseUrl)) }) app.delete<{ Params: { id: string } }>( '/install-links/:id', async (req) => { const row = repos.getInstallLink(app.db, req.params.id) if (!row) throw new AppError('NOT_FOUND', 'Install link not found', 404) const updated = repos.revokeInstallLink(app.db, row.id) return mapInstallLink(updated!, config.publicBaseUrl) }, ) // Agents app.get('/agents', async () => { const all = repos.listAgents(app.db) return { items: all.map((a) => { const link = repos.getInstallLinkByAgentId(app.db, a.id) if (!link || link.revokedAt) { return mapAgent(a) } const urls = buildInstallUrls( config.publicBaseUrl, link.id, link.slug, link.platform === 'mikrotik' ? 'mikrotik' : 'linux', ) return mapAgent(a, { installCurl: urls.curl.by_slug, installLinkId: link.id, }) }), } }) 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 } Querystring: { limit_cidrs?: 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) const limitRaw = Number(req.query.limit_cidrs ?? '50') const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(0, Math.floor(limitRaw)), 5000) : 50 return { default_action: policy.defaultAction, hash: policy.hash, generation: policy.generation, sync_interval_sec: policy.syncIntervalSec, apply_version: policy.applyVersion, summary: { sets: policy.summary.sets, rules_deny: policy.summary.rulesDeny, rules_allow: policy.summary.rulesAllow, cidrs_deny: policy.summary.cidrsDeny, cidrs_allow: policy.summary.cidrsAllow, overrides: policy.summary.overrides, conflicts_dropped: policy.summary.conflictsDropped, }, chain: policy.chain.map((s) => ({ set_id: s.setId, set_name: s.setName, rule_id: s.ruleId, action: s.action, source_kind: s.sourceKind, source_label: s.sourceLabel, cidr_count: s.cidrCount, })), deny_cidrs: truncateCidrs(policy.denyCidrs, limit), allow_cidrs: truncateCidrs(policy.allowCidrs, limit), deny_cidrs_total: policy.denyCidrs.length, allow_cidrs_total: policy.allowCidrs.length, } }) 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 nextDefault = body.default_action const updated = repos.updateAgent(app.db, a.id, { name: body.name, defaultAction: nextDefault, settingsJson: body.settings ? JSON.stringify(body.settings) : undefined, policyGeneration: nextDefault && nextDefault !== a.defaultAction ? a.policyGeneration + 1 : a.policyGeneration, }) auditMutation(app, config, req, { action: 'agent.update', targetType: 'app_resource', targetId: a.id, summary: `Обновлён агент: ${updated!.name}`, details: { agent_id: a.id, default_action: nextDefault, name: body.name, }, }) 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(), }) repos.ensureSharedSetAssigned(app.db, a.id) auditMutation(app, config, req, { action: 'agent.approve', targetType: 'app_resource', targetId: a.id, summary: `Агент одобрен: ${updated!.name}`, details: { agent_id: a.id }, }) 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(), }) auditMutation(app, config, req, { action: 'agent.revoke', severity: 'warning', targetType: 'app_resource', targetId: a.id, summary: `Агент отозван: ${updated!.name}`, details: { agent_id: a.id }, }) return mapAgent(updated!) }) app.delete<{ Params: { id: string } }>('/agents/:id', async (req) => { const a = repos.getAgent(app.db, req.params.id) repos.deleteAgent(app.db, req.params.id) if (a) { auditMutation(app, config, req, { action: 'agent.delete', severity: 'warning', targetType: 'app_resource', targetId: a.id, summary: `Агент удалён: ${a.name}`, details: { agent_id: a.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) auditMutation(app, config, req, { action: 'agent.clone_rules', targetType: 'app_resource', targetId: updated.id, summary: `Правила скопированы с ${req.params.sourceId} на ${updated.name}`, details: { agent_id: updated.id, source_agent_id: req.params.sourceId, include_overrides: body.include_overrides ?? false, }, }) 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) auditMutation(app, config, req, { action: 'override.create', targetType: 'app_resource', targetId: row!.id, summary: `Override ${body.action} ${body.cidr} для ${a.name}`, details: { override_id: row!.id, agent_id: a.id, cidr: body.cidr, action: body.action, }, }) 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) auditMutation(app, config, req, { action: 'override.delete', severity: 'warning', targetType: 'app_resource', targetId: req.params.overrideId, summary: `Override удалён у агента ${req.params.id}`, details: { override_id: req.params.overrideId, agent_id: 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 type = body.type === 'domains' ? 'static' : body.type const id = crypto.randomUUID() const list = repos.insertIpList(app.db, { id, name: body.name, type, configJson: JSON.stringify(body.config ?? {}), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) if (body.entries?.length && isManualListType(type)) { try { await addListEntries(app.db, id, { values: body.entries }) } catch (err) { repos.deleteIpList(app.db, id) throw new AppError( 'VALIDATION_ERROR', err instanceof Error ? err.message : String(err), 400, ) } } else if (!isManualListType(type)) { await refreshIpList(app.db, id) } auditMutation(app, config, req, { action: 'list.create', targetType: 'app_resource', targetId: list!.id, summary: `Создан список: ${list!.name}`, details: { list_id: list!.id, type: list!.type }, }) 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 detail = mapListDetail(app.db, req.params.id) if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404) return detail }) app.post<{ Params: { id: string } }>( '/lists/:id/entries', async (req) => { const l = repos.getIpList(app.db, req.params.id) if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) const body = listEntriesBodySchema.parse(req.body) try { const result = await addListEntries(app.db, l.id, { values: body.values, items: body.items, }) auditMutation(app, config, req, { action: 'list.entries.add', targetType: 'app_resource', targetId: l.id, summary: `Добавлены записи в список: ${l.name}`, details: { list_id: l.id, entry_count: result.entries.length, }, }) return mapListDetail(app.db, l.id) ?? result } catch (err) { throw new AppError( 'VALIDATION_ERROR', err instanceof Error ? err.message : String(err), 400, ) } }, ) app.delete<{ Params: { id: string } }>( '/lists/:id/entries', async (req) => { const l = repos.getIpList(app.db, req.params.id) if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) const body = deleteListEntryBodySchema.parse(req.body) try { await deleteListEntry(app.db, l.id, body.value) auditMutation(app, config, req, { action: 'list.entries.delete', severity: 'warning', targetType: 'app_resource', targetId: l.id, summary: `Удалена запись из списка: ${l.name}`, details: { list_id: l.id, value: body.value }, }) return mapListDetail(app.db, l.id) } catch (err) { throw new AppError( 'VALIDATION_ERROR', err instanceof Error ? err.message : String(err), 400, ) } }, ) app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => { const l = repos.getIpList(app.db, req.params.id) await refreshIpList(app.db, req.params.id) const detail = mapListDetail(app.db, req.params.id) if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404) auditMutation(app, config, req, { action: 'list.refresh', targetType: 'app_resource', targetId: req.params.id, summary: `Обновлён список: ${l?.name ?? req.params.id}`, details: { list_id: req.params.id }, }) return detail }) app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => { const l = repos.getIpList(app.db, req.params.id) repos.deleteIpList(app.db, req.params.id) if (l) { auditMutation(app, config, req, { action: 'list.delete', severity: 'warning', targetType: 'app_resource', targetId: l.id, summary: `Список удалён: ${l.name}`, details: { list_id: l.id }, }) } return { ok: true } }) // Policy sets app.get('/policy-sets', async () => ({ items: repos.listPolicySets(app.db).map((s) => mapPolicySet(s, app.db)), })) app.get<{ Params: { id: string } }>('/policy-sets/:id', async (req) => { const s = repos.getPolicySet(app.db, req.params.id) if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) return { ...mapPolicySet(s, app.db), agent_ids: repos.listAgentIdsForSet(app.db, s.id), } }) app.post('/policy-sets', async (req) => { const body = createPolicySetBodySchema.parse(req.body) const row = repos.insertPolicySet(app.db, { id: crypto.randomUUID(), name: body.name.trim(), description: body.description ?? null, enabled: body.enabled === false ? 0 : 1, policyMode: 'blacklist', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) auditMutation(app, config, req, { action: 'policy_set.create', targetType: 'app_resource', targetId: row!.id, summary: `Создан набор политик: ${row!.name}`, details: { set_id: row!.id }, }) return mapPolicySet(row!, app.db) }) app.patch<{ Params: { id: string } }>('/policy-sets/:id', async (req) => { const body = patchPolicySetBodySchema.parse(req.body) const s = repos.getPolicySet(app.db, req.params.id) if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) const updated = repos.updatePolicySet(app.db, s.id, { name: body.name?.trim(), description: body.description, enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0, }) if (body.enabled !== undefined) { repos.bumpAgentsForSet(app.db, s.id) } auditMutation(app, config, req, { action: 'policy_set.update', targetType: 'app_resource', targetId: s.id, summary: `Обновлён набор политик: ${updated!.name}`, details: { set_id: s.id, enabled: body.enabled, name: body.name, }, }) return mapPolicySet(updated!, app.db) }) app.delete<{ Params: { id: string } }>('/policy-sets/:id', async (req) => { const s = repos.getPolicySet(app.db, req.params.id) try { const agentIds = repos.listAgentIdsForSet(app.db, req.params.id) repos.deletePolicySet(app.db, req.params.id) for (const id of agentIds) repos.bumpAgentGeneration(app.db, id) if (s) { auditMutation(app, config, req, { action: 'policy_set.delete', severity: 'warning', targetType: 'app_resource', targetId: s.id, summary: `Набор политик удалён: ${s.name}`, details: { set_id: s.id, agents_affected: agentIds.length }, }) } } catch (err) { throw new AppError( 'VALIDATION_ERROR', err instanceof Error ? err.message : String(err), 400, ) } return { ok: true } }) app.get<{ Params: { id: string } }>( '/policy-sets/:id/rules', async (req) => { const s = repos.getPolicySet(app.db, req.params.id) if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) return { items: repos .listPolicyRules(app.db, s.id) .map((r) => mapPolicyRule(r, app.db)), } }, ) app.put<{ Params: { id: string } }>( '/agents/:id/policy-sets', async (req) => { const a = repos.getAgent(app.db, req.params.id) if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) const body = putAgentPolicySetsBodySchema.parse(req.body) for (const setId of body.set_ids) { if (!repos.getPolicySet(app.db, setId)) { throw new AppError('NOT_FOUND', `Policy set not found: ${setId}`, 404) } } try { repos.setAgentPolicySets(app.db, a.id, body.set_ids) } catch (err) { throw new AppError( 'VALIDATION_ERROR', err instanceof Error ? err.message : String(err), 400, ) } auditMutation(app, config, req, { action: 'agent.policy_sets.update', targetType: 'app_resource', targetId: a.id, summary: `Наборы политик агента ${a.name} обновлены`, details: { agent_id: a.id, set_ids: body.set_ids }, }) return { items: repos.listSetsForAgent(app.db, a.id).map((s) => ({ set_id: s.setId, sort: s.sort, name: s.name, description: s.description, enabled: s.enabled === 1, })), } }, ) app.get<{ Params: { id: string } }>( '/agents/:id/policy-sets', async (req) => { const a = repos.getAgent(app.db, req.params.id) if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) return { items: repos.listSetsForAgent(app.db, a.id).map((s) => ({ set_id: s.setId, sort: s.sort, name: s.name, description: s.description, enabled: s.enabled === 1, })), } }, ) // Rules app.get<{ Querystring: { set_id?: string; agent_id?: string } }>( '/rules', async (req) => { if (req.query.agent_id) { return { items: repos .listPolicyRulesForAgent(app.db, req.query.agent_id) .map((r) => mapPolicyRule(r, app.db)), } } const items = repos .listPolicyRules(app.db, req.query.set_id) .map((r) => mapPolicyRule(r, app.db)) return { items } }, ) app.post('/rules', async (req) => { const body = createPolicyRuleBodySchema.parse(req.body) const set = repos.getPolicySet(app.db, body.set_id) if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404) const hostname = body.hostname?.trim() || null const cidr = body.cidr?.trim() || null const listId = body.list_id?.trim() || null if (hostname) { try { await resolveHostnameToCidrs(hostname) } catch (err) { throw new AppError( 'VALIDATION_ERROR', err instanceof Error ? err.message : String(err), 400, ) } } if (listId && !repos.getIpList(app.db, listId)) { throw new AppError('NOT_FOUND', 'IP list not found', 404) } const priority = body.priority ?? repos.nextRulePriority(app.db, body.set_id) const id = crypto.randomUUID() const row = repos.insertPolicyRule(app.db, { id, setId: body.set_id, priority, action: body.action, enabled: body.enabled === false ? 0 : 1, listId, cidr, hostname, comment: body.comment ?? null, createdByUserId: req.authUser?.id, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) if (hostname) { try { await resolveAndStoreHostnameRule(app.db, id, hostname) } catch (err) { repos.deletePolicyRule(app.db, id) throw new AppError( 'VALIDATION_ERROR', err instanceof Error ? err.message : String(err), 400, ) } } repos.bumpAgentsForSet(app.db, body.set_id) auditMutation(app, config, req, { action: 'rule.create', targetType: 'app_resource', targetId: row!.id, summary: `Создано правило ${body.action} в наборе ${set.name}`, details: { rule_id: row!.id, set_id: body.set_id, action: body.action, priority, }, }) return mapPolicyRule(row!, app.db) }) app.patch<{ Params: { id: string } }>('/rules/:id', async (req) => { const body = patchPolicyRuleBodySchema.parse(req.body) const rule = repos.getPolicyRule(app.db, req.params.id) if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404) const updated = repos.updatePolicyRule(app.db, rule.id, { enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0, action: body.action, comment: body.comment, priority: body.priority, }) repos.bumpAgentsForSet(app.db, rule.setId) auditMutation(app, config, req, { action: 'rule.update', targetType: 'app_resource', targetId: rule.id, summary: `Обновлено правило ${rule.id}`, details: { rule_id: rule.id, set_id: rule.setId, enabled: body.enabled, action: body.action, priority: body.priority, }, }) return mapPolicyRule(updated!, app.db) }) app.put<{ Params: { id: string } }>( '/policy-sets/:id/rules/reorder', async (req) => { const body = reorderPolicyRulesBodySchema.parse(req.body) const s = repos.getPolicySet(app.db, req.params.id) if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) try { repos.reorderPolicyRules(app.db, s.id, body.ordered_ids) } catch (err) { throw new AppError( 'VALIDATION_ERROR', err instanceof Error ? err.message : String(err), 400, ) } repos.bumpAgentsForSet(app.db, s.id) auditMutation(app, config, req, { action: 'rule.reorder', targetType: 'app_resource', targetId: s.id, summary: `Порядок правил изменён в наборе ${s.name}`, details: { set_id: s.id, ordered_ids: body.ordered_ids }, }) return { items: repos .listPolicyRules(app.db, s.id) .map((r) => mapPolicyRule(r, app.db)), } }, ) app.delete<{ Params: { id: string } }>('/rules/:id', async (req) => { const rule = repos.getPolicyRule(app.db, req.params.id) if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404) repos.deletePolicyRule(app.db, req.params.id) repos.bumpAgentsForSet(app.db, rule.setId) auditMutation(app, config, req, { action: 'rule.delete', severity: 'warning', targetType: 'app_resource', targetId: rule.id, summary: `Правило удалено из набора ${rule.setId}`, details: { rule_id: rule.id, set_id: rule.setId }, }) 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.post<{ Params: { id: string } }>( '/agents/:id/stats/reset', async (req) => { const agent = repos.getAgent(app.db, req.params.id) if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404) const updated = repos.updateAgent(app.db, agent.id, { lastApplyPacketsDropped: 0, lastApplyPacketsAccepted: 0, }) repos.deleteStatsSamplesForAgent(app.db, agent.id) auditMutation(app, config, req, { action: 'agent.stats_reset', severity: 'info', targetType: 'app_resource', targetId: agent.id, summary: `Сброшена статистика counters агента ${agent.name}`, details: { agent_id: agent.id }, }) return mapAgent(updated!) }, ) 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, })), })) /** Proxy EvoBGP communities for UI autocomplete. */ app.get('/integrations/evobgp/communities', async () => { const apiUrl = repos.getSetting(app.db, 'evobgp_api_url') const token = repos.getSetting(app.db, 'evobgp_api_token') if (!apiUrl || !token) { throw new AppError( 'VALIDATION_ERROR', 'Настройте evobgp_api_url и evobgp_api_token', 400, ) } const base = apiUrl.replace(/\/$/, '') const res = await fetch(`${base}/v1/communities?limit=200`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', }, signal: AbortSignal.timeout(20_000), }) if (!res.ok) { throw new AppError( 'UPSTREAM_ERROR', `EvoBGP communities HTTP ${res.status}`, 502, ) } const data = (await res.json()) as { items?: { id?: string community?: string title?: string | null }[] } const items = (data.items ?? []) .filter((x) => x.id && x.community) .map((x) => ({ id: x.id!, community: x.community!, title: x.title ?? null, })) return { items } }) // 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 } }) }