Files
EvoFirewall/apps/api/src/routes/agent.ts
T
Denozordec 69e903aa1b
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m26s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
feat(api, web): enhance agent traffic statistics and update installation scripts
- Improved the collection and reporting of agent traffic statistics, including total packets dropped and accepted, to provide a more comprehensive view of agent performance.
- Updated the `evofw-firewall.sh` script to capture and report traffic statistics before chain recreation, ensuring accurate data retention.
- Enhanced the installation script to support updates on already-installed agents, allowing for script and timer refresh without re-enrollment, while preserving existing credentials.
- Refactored UI components to utilize new traffic statistics, improving clarity and user experience in displaying agent performance metrics.

These changes enhance the overall functionality and usability of the agent management system, providing better insights and easier updates for users.
2026-07-23 19:47:17 +07:00

236 lines
7.9 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { enrollBodySchema, applyReportBodySchema } from '@evofw/shared'
import type { AppConfig } from '../config.js'
import { hashToken } from '../plugins/auth.js'
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
import { renderMikrotikPolicyRsc } from '../services/policy/mikrotik-rsc.js'
import { AppError } from '../plugins/error-handler.js'
import { resolveAgentScriptsDir } from '../services/agent-scripts-path.js'
import { resolveAndRenderInstall } from '../services/install-links.js'
const scriptsDir = resolveAgentScriptsDir()
export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get('/v1/agent/install.sh', async (_req, reply) => {
const body = readFileSync(join(scriptsDir, 'install.sh'), 'utf-8')
return reply.type('text/x-shellscript').send(body)
})
app.get<{ Params: { id: string } }>(
'/agent-install/:id',
async (req, reply) => {
const link = repos.getInstallLink(app.db, req.params.id)
if (!link) throw new AppError('NOT_FOUND', 'Install link not found', 404)
const { body, contentType } = resolveAndRenderInstall(
app.db,
link,
config.publicBaseUrl,
config.enrollSeed,
)
return reply.type(contentType).send(body)
},
)
app.get('/v1/agent/sync-script', async (_req, reply) => {
const body = readFileSync(join(scriptsDir, 'evofw-firewall.sh'), 'utf-8')
return reply.type('text/x-shellscript').send(body)
})
app.get('/v1/agent/uninstall.sh', async (_req, reply) => {
const body = readFileSync(join(scriptsDir, 'uninstall.sh'), 'utf-8')
return reply.type('text/x-shellscript').send(body)
})
app.get('/v1/agent/mikrotik-install.rsc', async (_req, reply) => {
const body = readFileSync(
join(scriptsDir, 'mikrotik-install.rsc'),
'utf-8',
)
return reply.type('text/plain').send(body)
})
app.post('/v1/agent/enroll', async (req, reply) => {
const seed = req.headers['x-evofw-seed']
const expected =
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
if (!seed || String(seed) !== expected) {
throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401)
}
const body = enrollBodySchema.parse(req.body)
const tokenHash = hashToken(body.token)
const existingByToken = repos.getAgentByTokenHash(app.db, tokenHash)
if (existingByToken) {
throw new AppError('CONFLICT', 'Token already enrolled', 409)
}
if (body.install_link_id) {
const link = repos.getInstallLink(app.db, body.install_link_id)
if (!link) {
throw new AppError('NOT_FOUND', 'Install link not found', 404)
}
if (link.revokedAt) {
throw new AppError('GONE', 'Install link revoked', 410)
}
if (!link.agentId) {
throw new AppError('CONFLICT', 'Install link has no agent', 409)
}
const invited = repos.getAgent(app.db, link.agentId)
if (!invited) {
throw new AppError('NOT_FOUND', 'Agent not found', 404)
}
if (invited.status !== 'invited' && invited.status !== 'pending') {
throw new AppError(
'CONFLICT',
`Agent status is ${invited.status}`,
409,
)
}
const agent = repos.updateAgent(app.db, invited.id, {
name: body.name,
hostname: body.hostname ?? null,
platform: body.platform ?? invited.platform,
tokenPrefix: body.token.slice(0, 12),
tokenHash,
status: 'pending',
clientVersion: body.client_version ?? null,
lastSeenAt: new Date().toISOString(),
lastSeenIp: req.ip,
})
return reply.code(201).send({
client_id: agent!.id,
id: agent!.id,
status: agent!.status,
name: agent!.name,
})
}
const id = crypto.randomUUID()
const now = new Date().toISOString()
const agent = repos.insertAgent(app.db, {
id,
name: body.name,
hostname: body.hostname ?? null,
platform: body.platform ?? 'linux',
tokenPrefix: body.token.slice(0, 12),
tokenHash,
status: 'pending',
defaultAction: 'accept',
policyGeneration: 1,
clientVersion: body.client_version ?? null,
settingsJson: '{}',
createdAt: now,
lastSeenAt: now,
lastSeenIp: req.ip,
})
return reply.code(201).send({
client_id: agent!.id,
id: agent!.id,
status: agent!.status,
name: agent!.name,
})
})
app.get('/v1/agent/policy', async (req) => {
const agentId = req.agentId!
// Record contact first — empty policy (no rule sets) is a valid online state.
repos.updateAgent(app.db, agentId, {
lastSeenAt: new Date().toISOString(),
lastSeenIp: req.ip,
})
const policy = evaluateAgentPolicy(app.db, agentId)
return {
generation: policy.generation,
hash: policy.hash,
apply_version: policy.applyVersion,
default_action: policy.defaultAction,
policy_mode: policy.policyMode,
deny_cidrs: policy.denyCidrs,
allow_cidrs: policy.allowCidrs,
sync_interval_sec: policy.syncIntervalSec,
// compat: prefixes = deny when default accept, else allow (legacy single-bag clients)
prefixes:
policy.defaultAction === 'accept'
? policy.denyCidrs
: policy.allowCidrs,
total:
policy.defaultAction === 'accept'
? policy.denyCidrs.length
: policy.allowCidrs.length,
}
})
app.get('/v1/agent/policy.rsc', async (req, reply) => {
const agentId = req.agentId!
repos.updateAgent(app.db, agentId, {
lastSeenAt: new Date().toISOString(),
lastSeenIp: req.ip,
})
const policy = evaluateAgentPolicy(app.db, agentId)
return reply.type('text/plain').send(renderMikrotikPolicyRsc(policy))
})
app.post('/v1/agent/apply-report', async (req) => {
const agentId = req.agentId!
const body = applyReportBodySchema.parse(req.body)
const now = new Date().toISOString()
const prev = repos.getAgent(app.db, agentId)
const reportedDropped = body.packets_dropped ?? 0
const reportedAccepted = body.packets_accepted ?? 0
const prevDropped = prev?.lastApplyPacketsDropped ?? 0
const prevAccepted = prev?.lastApplyPacketsAccepted ?? 0
// Absolute-since-chain-create from agent. If counters reset (re-apply),
// treat the new absolute as the delta; else add the increase.
const deltaDropped =
reportedDropped >= prevDropped
? reportedDropped - prevDropped
: reportedDropped
const deltaAccepted =
reportedAccepted >= prevAccepted
? reportedAccepted - prevAccepted
: reportedAccepted
const totalDropped = (prev?.totalPacketsDropped ?? 0) + deltaDropped
const totalAccepted = (prev?.totalPacketsAccepted ?? 0) + deltaAccepted
repos.updateAgent(app.db, agentId, {
lastApplyAt: now,
lastApplyStatus: body.status,
lastApplyError: body.error ?? null,
lastApplyPrefixCount: body.prefix_count ?? 0,
lastApplyPacketsDropped: reportedDropped,
lastApplyPacketsAccepted: reportedAccepted,
totalPacketsDropped: totalDropped,
totalPacketsAccepted: totalAccepted,
lastApplyKernelMethod: body.kernel_method ?? null,
lastSeenAt: now,
lastSeenIp: req.ip,
})
repos.insertStatsSample(app.db, {
id: crypto.randomUUID(),
agentId,
packetsDropped: reportedDropped,
packetsAccepted: reportedAccepted,
prefixCount: body.prefix_count ?? 0,
kernelMethod: body.kernel_method ?? null,
recordedAt: now,
})
return { ok: true }
})
app.post('/v1/agent/heartbeat', async (req) => {
const agentId = req.agentId!
repos.updateAgent(app.db, agentId, {
lastSeenAt: new Date().toISOString(),
lastSeenIp: req.ip,
})
return { ok: true }
})
}