quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
Implement reliable agent apply, telemetry v2, analytics retention, guarded rollouts, enrollment, labels and webhooks. Improve ReUI frontend data safety and bounded list loading, and add Docker smoke, recovery and resource checks. Document local verification and pending native acceptance.
161 lines
6.4 KiB
TypeScript
161 lines
6.4 KiB
TypeScript
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 }
|
|
})
|
|
}
|