Files
EvoFirewall/apps/api/src/routes/control.ts
T
Denozordec 1b7d301153
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m57s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
feat(api, web): add agent stats reset functionality and enhance UI components
- Implemented a new API endpoint to reset agent statistics, allowing for better management of agent performance data.
- Updated the AgentCard component to display traffic statistics in a consolidated format, improving clarity for users.
- Enhanced the AgentDetailView to include a button for resetting agent stats, providing a direct action for users.
- Refactored the AgentFleetDataGrid to show combined traffic metrics, streamlining data presentation.
- Added a utility function to delete stats samples for agents in the database, ensuring data integrity.

These changes improve the user experience by providing more intuitive controls and clearer data representation for agent statistics.
2026-07-23 19:20:15 +07:00

1073 lines
33 KiB
TypeScript

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<ReturnType<typeof repos.getAgent>>,
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<ReturnType<typeof repos.getPolicySet>>,
db: Parameters<typeof repos.countRulesInSet>[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<ReturnType<typeof repos.getPolicyRule>>,
db: Parameters<typeof repos.listResolvedForRule>[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<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 }
})
}