Files
EvoFirewall/apps/api/src/app.ts
T
Denozordec 40030ce06c fix(api): fail-safe прод-старт, транзакции и санитизация install-скриптов
- прод-режим отказывается стартовать без 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) + тесты
2026-09-20 18:51:47 +07:00

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
}