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.
184 lines
6.2 KiB
TypeScript
184 lines
6.2 KiB
TypeScript
import { resolve } from 'node:path'
|
|
import Fastify from 'fastify'
|
|
import {
|
|
serializerCompiler,
|
|
validatorCompiler,
|
|
type ZodTypeProvider,
|
|
} from '@fastify/type-provider-zod'
|
|
import { AsyncTask, CronJob } from 'toad-scheduler'
|
|
import type { AppConfig } from './config.js'
|
|
import { loadConfig } from './config.js'
|
|
import authPlugin from './plugins/auth.js'
|
|
import operationalPlugin from './plugins/operational.js'
|
|
import corsPlugin from './plugins/cors.js'
|
|
import dbPlugin from './plugins/db.js'
|
|
import errorHandlerPlugin from './plugins/error-handler.js'
|
|
import { healthRoutes } from './routes/health.js'
|
|
import { controlRoutes } from './routes/control.js'
|
|
import { auditRoutes } from './routes/audit.js'
|
|
import { agentRoutes } from './routes/agent.js'
|
|
import { enrollmentRoutes } from './routes/enrollment.js'
|
|
import { policyRolloutsRoutes } from './routes/policy-rollouts.js'
|
|
import { webhooksRoutes } from './routes/webhooks.js'
|
|
import { refreshAllLists } from './services/lists/refresh.js'
|
|
import { runStatsRetention } from './services/analytics.js'
|
|
import { repos } from '@evofw/db'
|
|
import {
|
|
isValidInstallSlug,
|
|
resolveAndRenderInstall,
|
|
} from './services/install-links.js'
|
|
|
|
export interface BuildAppOptions {
|
|
config?: AppConfig
|
|
memory?: boolean
|
|
}
|
|
|
|
export async function buildApp(opts: BuildAppOptions = {}) {
|
|
const config = opts.config ?? loadConfig()
|
|
|
|
const app = Fastify({
|
|
trustProxy: config.trustProxy ?? false,
|
|
logger: { level: config.logLevel },
|
|
genReqId: () => crypto.randomUUID(),
|
|
}).withTypeProvider<ZodTypeProvider>()
|
|
|
|
// Correlation id in every response and in the error envelope.
|
|
app.addHook('onSend', async (req, reply) => {
|
|
reply.header('x-request-id', req.id)
|
|
})
|
|
|
|
app.setValidatorCompiler(validatorCompiler)
|
|
app.setSerializerCompiler(serializerCompiler)
|
|
|
|
await app.register(import('@fastify/sensible'))
|
|
// CSP only guards the served SPA; in dev the Vite server proxies API
|
|
// requests same-origin and injects its own HMR scripts.
|
|
await app.register(import('@fastify/helmet'), {
|
|
contentSecurityPolicy:
|
|
config.staticDir !== null
|
|
? {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
imgSrc: ["'self'", 'data:'],
|
|
fontSrc: ["'self'", 'data:'],
|
|
// app-switcher talks to the auth portal directly from the browser
|
|
connectSrc: ["'self'", config.authPortalUrl],
|
|
objectSrc: ["'none'"],
|
|
baseUri: ["'self'"],
|
|
frameAncestors: ["'none'"],
|
|
},
|
|
}
|
|
: false,
|
|
})
|
|
await app.register(corsPlugin, { config })
|
|
await app.register(errorHandlerPlugin)
|
|
await app.register(dbPlugin, { config, memory: opts.memory })
|
|
await app.register(authPlugin, { config })
|
|
await app.register(operationalPlugin, { config, memory: opts.memory })
|
|
|
|
// Seed enroll_seed into settings if empty
|
|
if (!repos.getSetting(app.db, 'enroll_seed')) {
|
|
repos.setSetting(app.db, 'enroll_seed', config.enrollSeed)
|
|
}
|
|
|
|
await app.register(healthRoutes)
|
|
await app.register(agentRoutes, { config })
|
|
await app.register(enrollmentRoutes, { config })
|
|
|
|
await app.register(
|
|
async (protectedApi) => {
|
|
protectedApi.addHook('onRequest', app.requireAuth)
|
|
await protectedApi.register(controlRoutes, { config })
|
|
await protectedApi.register(auditRoutes)
|
|
await protectedApi.register(policyRolloutsRoutes, { config })
|
|
await protectedApi.register(webhooksRoutes, { config, memory: opts.memory })
|
|
},
|
|
{ prefix: '/api/v1' },
|
|
)
|
|
|
|
const staticDir = config.staticDir ?? resolve(process.cwd(), 'static')
|
|
if (config.staticDir !== null) {
|
|
await app.register(import('@fastify/static'), {
|
|
root: staticDir,
|
|
wildcard: false,
|
|
})
|
|
}
|
|
|
|
app.setNotFoundHandler(async (request, reply) => {
|
|
const path = request.url.split('?')[0] ?? ''
|
|
if (path === '/api' || path.startsWith('/api/') || path === '/v1' || path.startsWith('/v1/')) {
|
|
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not Found', request_id: request.id } })
|
|
}
|
|
const segment = path.startsWith('/') ? path.slice(1) : path
|
|
if (
|
|
request.method === 'GET' &&
|
|
segment &&
|
|
!segment.includes('/') &&
|
|
isValidInstallSlug(segment)
|
|
) {
|
|
const link = repos.getInstallLinkBySlug(app.db, segment)
|
|
if (link) {
|
|
const { body, contentType } = resolveAndRenderInstall(
|
|
app.db,
|
|
link,
|
|
config.publicBaseUrl,
|
|
config.enrollSeed,
|
|
)
|
|
return reply.header('Cache-Control', 'no-store').type(contentType).send(body)
|
|
}
|
|
}
|
|
|
|
if (config.staticDir !== null) {
|
|
return reply.sendFile('index.html')
|
|
}
|
|
return reply.code(404).send({
|
|
error: { code: 'NOT_FOUND', message: 'Not Found' },
|
|
})
|
|
})
|
|
|
|
if (!opts.memory) {
|
|
await app.register(import('@fastify/schedule'))
|
|
const task = new AsyncTask(
|
|
'list-refresh',
|
|
async () => {
|
|
await refreshAllLists(app.db)
|
|
app.log.info('list refresh completed')
|
|
},
|
|
(err) => {
|
|
app.log.warn({ err }, 'list refresh failed')
|
|
},
|
|
)
|
|
app.scheduler.addCronJob(
|
|
new CronJob({ cronExpression: '0 */5 * * * *' }, task, {
|
|
preventOverrun: true,
|
|
}),
|
|
)
|
|
|
|
// Raw stats samples grow ~1 row per agent per sync interval; drop old
|
|
// ones daily. Lifetime totals live on agents; aggregates are kept.
|
|
const retentionAbort = new AbortController()
|
|
app.addHook('onClose', async () => { retentionAbort.abort() })
|
|
const runRetention = async () => {
|
|
const deleted = await runStatsRetention(app.db, new Date().toISOString(), 1000, retentionAbort.signal)
|
|
if (deleted > 0) app.log.info({ deleted }, 'stats retention completed')
|
|
}
|
|
app.scheduler.addCronJob(
|
|
new CronJob(
|
|
{ cronExpression: '17 * * * *' },
|
|
new AsyncTask('stats-retention', runRetention, (err) => {
|
|
app.log.warn({ err }, 'stats retention failed')
|
|
}),
|
|
{ preventOverrun: true },
|
|
),
|
|
)
|
|
// First cleanup right at startup, not only after the next 03:17.
|
|
runRetention().catch((err) =>
|
|
app.log.warn({ err }, 'stats retention failed'),
|
|
)
|
|
}
|
|
|
|
return app
|
|
}
|