- 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]>
153 lines
4.9 KiB
TypeScript
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 }
|
|
})
|
|
}
|