- 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.
236 lines
7.9 KiB
TypeScript
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 }
|
|
})
|
|
}
|