import { readFileSync } from 'node:fs' import { createHash } from 'node:crypto' import { join } from 'node:path' import type { FastifyPluginAsync } from 'fastify' import { repos } from '@evofw/db' import { applyReportBodySchema, POLICY_BUDGET, POLICY_BUDGET_ERROR, agentPolicyQuerySchema, policyBudgetErrorEnvelopeSchema } from '@evofw/shared' import type { AppConfig } from '../config.js' import { evaluateAgentPolicy, policyBudgetExceeded } from '../services/policy/evaluate.js' import { renderMikrotikPolicyRsc } from '../services/policy/mikrotik-rsc.js' import { ingestTelemetry } from '../services/telemetry.js' import { AppError } from '../plugins/error-handler.js' import { resolveAgentScriptsDir } from '../services/agent-scripts-path.js' import { resolveAndRenderInstall } from '../services/install-links.js' import { getSyncScriptMeta, ifNoneMatchHits, } from '../services/sync-script.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.header('cache-control', 'no-store').type(contentType).send(body) }, ) app.get('/v1/agent/sync-script', async (req, reply) => { const meta = getSyncScriptMeta() reply.header('ETag', meta.etag) reply.header('X-Evofw-Script-Sha256', meta.sha256) const inm = req.headers['if-none-match'] if (ifNoneMatchHits(inm, meta.etag)) { return reply.code(304).send() } return reply.type('text/x-shellscript').send(meta.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.get('/v1/agent/policy', async (req, reply) => { const query = agentPolicyQuerySchema.parse(req.query) try { 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) const scriptSha = getSyncScriptMeta().sha256 const response = { generation: policy.generation, hash: policy.hash, apply_version: policy.applyVersion, default_action: policy.defaultAction, policy_mode: policy.policyMode, script_sha256: scriptSha, deny_cidrs: policy.denyCidrs, allow_cidrs: policy.allowCidrs, port_rules: policy.portRules.map((r) => ({ id: r.id, action: r.action, protocol: r.protocol, port_start: r.portStart, port_end: r.portEnd, src_cidrs: r.srcCidrs, })), 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, } const json = JSON.stringify(response) if (Buffer.byteLength(json) > POLICY_BUDGET.jsonBytes) policyBudgetExceeded('8MiB policy response JSON') const etag = `"policy-v1-${createHash('sha256').update(json).digest('hex')}"` repos.updateAgent(app.db, agentId, { desiredHash: policy.hash, desiredHashGeneration: policy.generation, lastPolicyDeliveredAt: new Date().toISOString() }) reply.header('etag', etag).header('cache-control', 'private, no-cache') if (ifNoneMatchHits(req.headers['if-none-match'], etag)) return reply.code(304).send() return reply.type('application/json').send(json) } catch (error) { if (query.transport === 'routeros' && error instanceof AppError && error.code === POLICY_BUDGET_ERROR) { reply.removeHeader('etag') return reply.header('cache-control', 'no-store').code(200).send(policyBudgetErrorEnvelopeSchema.parse({ error: { code: POLICY_BUDGET_ERROR, message: error.message.slice(0, 512) } })) } throw error } }) 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) const script = renderMikrotikPolicyRsc(policy) if (Buffer.byteLength(script) > POLICY_BUDGET.jsonBytes) policyBudgetExceeded('8MiB RouterOS policy response') repos.updateAgent(app.db, agentId, { desiredHash: policy.hash, desiredHashGeneration: policy.generation, lastPolicyDeliveredAt: new Date().toISOString() }) const etag = `"policy-rsc-v1-${createHash('sha256').update(script).digest('hex')}"` reply.header('etag', etag).header('cache-control', 'private, no-cache') if (ifNoneMatchHits(req.headers['if-none-match'], etag)) return reply.code(304).send() return reply.type('text/plain').send(script) }) app.post('/v1/agent/apply-report', async (req) => { const body = applyReportBodySchema.parse(req.body) const result = ingestTelemetry(app.db, req.agentId!, body, req.ip) return { ...result, ...(body.telemetry_version === 2 ? { report_id: body.report_id, sequence: body.sequence, sequence_floor: repos.getTelemetryState(app.db, req.agentId!)!.sequence } : {}) } }) 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 } }) }