feat(api, web): enhance agent management and linting capabilities
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m17s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added a new linting command for OpenAPI specifications in the package.json, improving code quality checks.
- Updated frontend documentation to clarify component usage and structure, including detailed descriptions for `SettingsShell` and `Auth callback`.
- Refactored agent-related API routes to streamline control-plane functionalities, consolidating multiple routes for better organization.
- Improved error handling in the API to provide more informative responses for validation errors, enhancing user feedback during interactions.

These changes enhance the overall development experience and improve the management of agents within the application.
This commit is contained in:
Denozordec
2026-07-30 14:13:05 +07:00
parent fb95ef22b3
commit f160992d94
68 changed files with 3155 additions and 6899 deletions
+1 -3
View File
@@ -101,9 +101,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
return reply.sendFile('index.html')
}
return reply.code(404).send({
type: 'about:blank',
title: 'Not Found',
status: 404,
error: { code: 'NOT_FOUND', message: 'Not Found' },
})
})
+8
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify'
import fp from 'fastify-plugin'
import { ZodError } from 'zod'
export class AppError extends Error {
constructor(
@@ -19,6 +20,13 @@ async function errorHandlerPlugin(app: FastifyInstance) {
error: { code: err.code, message: err.message },
})
}
if (err instanceof ZodError) {
const message =
err.issues.map((i) => i.message).join('; ') || 'Validation error'
return reply.code(400).send({
error: { code: 'VALIDATION_ERROR', message },
})
}
const e = err as { statusCode?: number; message?: string }
const status = e.statusCode ?? 500
const message =
+329
View File
@@ -0,0 +1,329 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import {
createOverrideBodySchema,
putAgentPolicySetsBodySchema,
patchAgentBodySchema,
cloneFromBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js'
import { buildInstallUrls } from '../services/install-links.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { mapAgent } from '../services/row-mappers.js'
export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get('/agents', async () => {
const all = repos.listAgents(app.db)
const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db)
return {
items: all.map((a) => {
const link = linksByAgent.get(a.id)
if (!link) {
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)
},
)
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 }
},
)
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,
})),
}
},
)
}
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import type { AppConfig } from '../config.js'
export const dashboardRoutes: 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.totalPacketsDropped ?? a.lastApplyPacketsDropped ?? 0),
0,
),
packets_accepted: all.reduce(
(s, a) => s + (a.totalPacketsAccepted ?? 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',
),
}
})
}
+88
View File
@@ -0,0 +1,88 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { createInstallLinkBodySchema } from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import {
mapInstallLink,
randomToken,
} from '../services/install-links.js'
import { hashToken } from '../plugins/auth.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
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)
},
)
}
@@ -0,0 +1,48 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { AppError } from '../plugins/error-handler.js'
export const integrationsEvobgpRoutes: FastifyPluginAsync = async (app) => {
/** 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 }
})
}
+185
View File
@@ -0,0 +1,185 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import {
createIpListBodySchema,
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 type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get('/lists', async () => {
const lists = repos.listIpLists(app.db)
const counts = repos.countEntriesByListIds(
app.db,
lists.map((l) => l.id),
)
const items = lists.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: counts.get(l.id) ?? 0,
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 }
})
}
+103
View File
@@ -0,0 +1,103 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import {
createPolicySetBodySchema,
patchPolicySetBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { mapPolicySet, mapPolicySets } from '../services/row-mappers.js'
export const policySetsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get('/policy-sets', async () => ({
items: mapPolicySets(app.db, repos.listPolicySets(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 }
})
}
+199
View File
@@ -0,0 +1,199 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import {
createPolicyRuleBodySchema,
patchPolicyRuleBodySchema,
reorderPolicyRulesBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import {
resolveAndStoreHostnameRule,
resolveHostnameToCidrs,
} from '../services/policy/resolve-hostname.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { mapPolicyRule } from '../services/row-mappers.js'
export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
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.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 }
})
}
+34
View File
@@ -0,0 +1,34 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { putSettingsBodySchema } from '@evofw/shared'
import type { AppConfig } from '../config.js'
export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
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 = putSettingsBodySchema.parse(req.body)
for (const [k, v] of Object.entries(body)) {
if (k === 'evobgp_api_token' && v === '********') continue
repos.setSetting(app.db, k, v)
}
return { ok: true }
})
}
+61
View File
@@ -0,0 +1,61 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { AppError } from '../plugins/error-handler.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { mapAgent } from '../services/row-mappers.js'
export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
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,
totalPacketsDropped: 0,
totalPacketsAccepted: 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,
})),
}))
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
jwtSecret: 'test',
jwtTtlHours: 24,
serverPort: 8080,
staticDir: null,
logLevel: 'error',
authRequired: false,
authIssuer: 'https://auth.test',
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
}
describe('agents CRUD critical paths', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('creates invite, approves, lists with install curl', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'ops-01', platform: 'linux' },
})
expect(created.statusCode).toBe(201)
const link = created.json() as { agent_id: string }
const approve = await app.inject({
method: 'POST',
url: `/api/v1/agents/${link.agent_id}/approve`,
})
expect(approve.statusCode).toBe(200)
const list = await app.inject({ method: 'GET', url: '/api/v1/agents' })
expect(list.statusCode).toBe(200)
const items = (list.json() as { items: { id: string; status: string }[] })
.items
const agent = items.find((a) => a.id === link.agent_id)
expect(agent?.status).toBe('approved')
})
it('creates policy set + rule and reorders', async () => {
const app = await appPromise
await app.ready()
const setRes = await app.inject({
method: 'POST',
url: '/api/v1/policy-sets',
payload: { name: 'test-set' },
})
expect(setRes.statusCode).toBe(200)
const set = setRes.json() as { id: string }
const r1 = await app.inject({
method: 'POST',
url: '/api/v1/rules',
payload: {
set_id: set.id,
action: 'deny',
cidr: '1.1.1.1/32',
},
})
expect(r1.statusCode).toBe(200)
const rule1 = r1.json() as { id: string }
const r2 = await app.inject({
method: 'POST',
url: '/api/v1/rules',
payload: {
set_id: set.id,
action: 'allow',
cidr: '8.8.8.8/32',
},
})
expect(r2.statusCode).toBe(200)
const rule2 = r2.json() as { id: string }
const reorder = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${set.id}/rules/reorder`,
payload: {
ordered_ids: [rule2.id, rule1.id],
},
})
expect(reorder.statusCode).toBe(200)
const rules = await app.inject({
method: 'GET',
url: `/api/v1/policy-sets/${set.id}/rules`,
})
expect(rules.statusCode).toBe(200)
const items = (rules.json() as { items: { id: string }[] }).items
expect(items[0]?.id).toBe(rule2.id)
})
})
+2 -5
View File
@@ -11,10 +11,7 @@ import {
type ListEntryInput,
} from '@evofw/shared'
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
function uniq(cidrs: string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
}
import { uniqCidrs } from '../uniq.js'
export function getListConfig(list: {
configJson: string
@@ -129,7 +126,7 @@ export async function rebuildManualListEntries(
delete config.domains
repos.updateIpList(db, listId, { configJson: JSON.stringify(config) })
const cidrs = uniq(all)
const cidrs = uniqCidrs(all)
repos.replaceIpListEntries(db, listId, cidrs)
return cidrs
} finally {
+4 -7
View File
@@ -7,10 +7,7 @@ import {
rebuildListCascade,
rebuildManualListEntries,
} from './entries.js'
function uniq(cidrs: string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
}
import { uniqCidrs } from '../uniq.js'
function hashCidrs(cidrs: string[]): string {
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
@@ -48,7 +45,7 @@ async function fetchJsonUrl(url: string): Promise<string[]> {
}
}
}
return uniq(out)
return uniqCidrs(out)
}
const UUID_RE =
@@ -113,9 +110,9 @@ async function fetchEvobgpCommunity(
}
let cidrs: string[] = []
if (Array.isArray(data.prefixes) && data.prefixes.length > 0) {
cidrs = uniq(data.prefixes)
cidrs = uniqCidrs(data.prefixes)
} else if (Array.isArray(data.items)) {
cidrs = uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean))
cidrs = uniqCidrs(data.items.map((i) => i.prefix ?? '').filter(Boolean))
}
return { cidrs, resolvedId }
}
+3 -14
View File
@@ -6,6 +6,7 @@ import {
legacyModeFromDefaultAction,
type DefaultAction,
} from '@evofw/shared'
import { uniqCidrs } from '../uniq.js'
export const POLICY_APPLY_VERSION = 2 as const
@@ -42,18 +43,6 @@ export type EvaluatedPolicy = {
}
}
function uniq(cidrs: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const c of cidrs) {
const t = c.trim()
if (!t || seen.has(t)) continue
seen.add(t)
out.push(t)
}
return out.sort()
}
function expandList(db: Db, listId: string | null | undefined): string[] {
if (!listId) return []
return repos.listIpListEntries(db, listId).map((e) => e.cidr)
@@ -159,9 +148,9 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
})
}
const denyCidrs = uniq(deny)
const denyCidrs = uniqCidrs(deny)
const denySet = new Set(denyCidrs)
const allowRaw = uniq(allow)
const allowRaw = uniqCidrs(allow)
const allowCidrs = allowRaw.filter((c) => !denySet.has(c))
const conflictsDropped = allowRaw.length - allowCidrs.length
const defaultAction = resolveDefaultAction(agent.defaultAction)
@@ -2,10 +2,7 @@ import { resolve4, resolve6 } from 'node:dns/promises'
import { createHash } from 'node:crypto'
import type { Db } from '@evofw/db'
import { repos } from '@evofw/db'
function uniq(cidrs: string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
}
import { uniqCidrs } from '../uniq.js'
function hashCidrs(cidrs: string[]): string {
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
@@ -30,7 +27,7 @@ export async function resolveHostnameToCidrs(hostname: string): Promise<string[]
/* ignore AAAA failures */
}
const cidrs = uniq(out)
const cidrs = uniqCidrs(out)
if (cidrs.length === 0) {
throw new Error(`DNS resolve failed for ${host}: no A/AAAA records`)
}
+87
View File
@@ -0,0 +1,87 @@
import { repos } from '@evofw/db'
export 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,
total_packets_dropped: a.totalPacketsDropped ?? 0,
total_packets_accepted: a.totalPacketsAccepted ?? 0,
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,
}
}
export function mapPolicySet(
s: NonNullable<ReturnType<typeof repos.getPolicySet>>,
db: Parameters<typeof repos.countRulesInSet>[0],
counts?: { rules: number; agents: number },
) {
return {
id: s.id,
name: s.name,
description: s.description,
enabled: s.enabled === 1,
rules_count: counts?.rules ?? repos.countRulesInSet(db, s.id),
agents_count: counts?.agents ?? repos.countAgentsForSet(db, s.id),
created_at: s.createdAt,
updated_at: s.updatedAt,
}
}
export function mapPolicySets(
db: Parameters<typeof repos.countRulesInSet>[0],
sets: NonNullable<ReturnType<typeof repos.getPolicySet>>[],
) {
const counts = repos.countRulesAndAgentsBySetIds(
db,
sets.map((s) => s.id),
)
return sets.map((s) => mapPolicySet(s, db, counts.get(s.id)))
}
export 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,
}
}
@@ -0,0 +1,82 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
import { repos } from '@evofw/db'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
jwtSecret: 'test',
jwtTtlHours: 24,
serverPort: 8080,
staticDir: null,
logLevel: 'error',
authRequired: false,
authIssuer: 'https://auth.test',
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
}
describe('settings + bumpAgentsForList', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('rejects unknown settings keys', async () => {
const app = await appPromise
await app.ready()
const res = await app.inject({
method: 'PUT',
url: '/api/v1/settings',
payload: { unknown_key: 'x' },
})
expect(res.statusCode).toBeGreaterThanOrEqual(400)
})
it('accepts show_quick_actions', async () => {
const app = await appPromise
await app.ready()
const res = await app.inject({
method: 'PUT',
url: '/api/v1/settings',
payload: { show_quick_actions: 'false' },
})
expect(res.statusCode).toBe(200)
const get = await app.inject({ method: 'GET', url: '/api/v1/settings' })
expect(get.json().show_quick_actions).toBe('false')
})
it('bumpAgentsForList no-ops when list has no rules', async () => {
const app = await appPromise
await app.ready()
const db = app.db
const agent = repos.insertAgent(db, {
id: 'ag-bump-1',
name: 'bump-test',
platform: 'linux',
tokenPrefix: 'tok',
tokenHash: 'hash-bump-1',
status: 'approved',
defaultAction: 'accept',
policyGeneration: 1,
settingsJson: '{}',
})
expect(agent?.policyGeneration).toBe(1)
const list = repos.insertIpList(db, {
id: 'list-unused',
name: 'unused',
type: 'static',
configJson: '{}',
})
expect(list).toBeTruthy()
repos.bumpAgentsForList(db, 'list-unused')
const after = repos.getAgent(db, 'ag-bump-1')
expect(after?.policyGeneration).toBe(1)
})
})
+17
View File
@@ -0,0 +1,17 @@
/** Trim, drop empty, dedupe (order not guaranteed — sorted for stable hashes). */
export function uniqCidrs(cidrs: readonly string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
}
/** Trim, drop empty, dedupe preserving first-seen order. */
export function uniqCidrsPreserveOrder(cidrs: readonly string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const c of cidrs) {
const t = c.trim()
if (!t || seen.has(t)) continue
seen.add(t)
out.push(t)
}
return out
}