- прод-режим отказывается стартовать без AUTH_REQUIRED и реальных секретов (opt-out через EVOFW_ALLOW_UNSAFE) - CORS: whitelist через CORS_ORIGINS вместо origin:true; CSP для раздаваемого SPA - транзакции для setAgentPolicySets, reorderPolicyRules, replaceResolvedForRule, replaceIpListEntries - install-скрипты: Zod-валидация имени ссылки, экранирование $ и контрольных символов в RouterOS-рендере - constant-time сравнение enroll-seed - опциональное шифрование токена EvoBGP в БД (EVOFW_SECRET_KEY, AES-256-GCM) и маскирование per-list api_token в ответах - graceful shutdown (SIGTERM/SIGINT) + тесты
147 lines
4.3 KiB
TypeScript
147 lines
4.3 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 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 { refreshAllLists } from './services/lists/refresh.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({
|
|
logger: { level: config.logLevel },
|
|
}).withTypeProvider<ZodTypeProvider>()
|
|
|
|
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(import('@fastify/rate-limit'), {
|
|
max: 300,
|
|
timeWindow: '1 minute',
|
|
})
|
|
await app.register(corsPlugin, { config })
|
|
await app.register(errorHandlerPlugin)
|
|
await app.register(dbPlugin, { config, memory: opts.memory })
|
|
await app.register(authPlugin, { config })
|
|
|
|
// 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(
|
|
async (protectedApi) => {
|
|
protectedApi.addHook('onRequest', app.requireAuth)
|
|
await protectedApi.register(controlRoutes, { config })
|
|
await protectedApi.register(auditRoutes)
|
|
},
|
|
{ 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] ?? ''
|
|
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.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,
|
|
}),
|
|
)
|
|
}
|
|
|
|
return app
|
|
}
|