- 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.
1073 lines
33 KiB
TypeScript
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 }
|
|
})
|
|
}
|