Files
EvoFirewall/apps/api/src/routes/agent.ts
T
DenozordecandCursor ef56da4d91
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m37s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
feat(api, web): implement short install link functionality for agents
- Added new API endpoints for creating, retrieving, and revoking install links for agents.
- Enhanced the agent installation process with short links accessible via `/agent-install/:id` and `/:slug`.
- Updated the README and documentation to reflect the new installation method and usage instructions.
- Refactored relevant components in the web application to support the new install link feature.
- Improved error handling and validation for install link operations.

Co-authored-by: Cursor <[email protected]>
2026-07-21 01:25:05 +07:00

153 lines
4.9 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
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 { AppError } from '../plugins/error-handler.js'
import { resolveAndRenderInstallScript } from '../services/install-links.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const scriptsDir = join(__dirname, '../agent-scripts')
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 script = resolveAndRenderInstallScript(
app.db,
link,
config.publicBaseUrl,
config.enrollSeed,
)
return reply.type('text/x-shellscript').send(script)
},
)
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/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 id = crypto.randomUUID()
const tokenHash = hashToken(body.token)
const existing = repos.getAgentByTokenHash(app.db, tokenHash)
if (existing) {
throw new AppError('CONFLICT', 'Token already enrolled', 409)
}
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',
policyMode: 'blacklist',
policyGeneration: 1,
clientVersion: body.client_version ?? null,
settingsJson: '{}',
createdAt: new Date().toISOString(),
})
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!
const policy = evaluateAgentPolicy(app.db, agentId)
repos.updateAgent(app.db, agentId, {
lastSeenAt: new Date().toISOString(),
lastSeenIp: req.ip,
})
return {
generation: policy.generation,
hash: policy.hash,
policy_mode: policy.policyMode,
deny_cidrs: policy.denyCidrs,
allow_cidrs: policy.allowCidrs,
sync_interval_sec: policy.syncIntervalSec,
// compat aliases for simple clients
prefixes:
policy.policyMode === 'blacklist'
? policy.denyCidrs
: policy.allowCidrs,
total:
policy.policyMode === 'blacklist'
? policy.denyCidrs.length
: policy.allowCidrs.length,
}
})
app.post('/v1/agent/apply-report', async (req) => {
const agentId = req.agentId!
const body = applyReportBodySchema.parse(req.body)
const now = new Date().toISOString()
repos.updateAgent(app.db, agentId, {
lastApplyAt: now,
lastApplyStatus: body.status,
lastApplyError: body.error ?? null,
lastApplyPrefixCount: body.prefix_count ?? 0,
lastApplyPacketsDropped: body.packets_dropped ?? 0,
lastApplyPacketsAccepted: body.packets_accepted ?? 0,
lastApplyKernelMethod: body.kernel_method ?? null,
lastSeenAt: now,
lastSeenIp: req.ip,
})
repos.insertStatsSample(app.db, {
id: crypto.randomUUID(),
agentId,
packetsDropped: body.packets_dropped ?? 0,
packetsAccepted: body.packets_accepted ?? 0,
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 }
})
}