import fp from 'fastify-plugin' import type { FastifyPluginAsync } from 'fastify' import { hasPermission, permissionForRequest } from '@evofw/shared' import { stat, statfs } from 'node:fs/promises' import { dirname } from 'node:path' import type { AppConfig } from '../config.js' import { AuditOutbox, AUDIT_QUEUE_LIMIT } from '../services/audit-outbox.js' type PermissionPolicy = 'public' | 'agent' | 'protected' | 'section-filtered' export interface RouteSecurity { method: string; url: string; policy: PermissionPolicy; permission: string | null } declare module 'fastify' { interface FastifyContextConfig { permissionPolicy?: PermissionPolicy; requiredPermission?: string | null } interface FastifyInstance { auditOutbox: AuditOutbox; routeSecurity: RouteSecurity[]; allowFailedAuthAttempt: (ip: string) => boolean } } export function routeSecurityFor(method: string, url: string): RouteSecurity { const path = url.split('?')[0]! if (['/v1/agent/policy', '/v1/agent/policy.rsc', '/v1/agent/apply-report', '/v1/agent/heartbeat', '/v1/agent/credentials', '/v1/agent/token-rotation'].includes(path)) return { method, url, policy: 'agent', permission: null } const read = ['GET', 'HEAD'].includes(method) const publicAgent = (path === '/v1/agent/enroll' && method === 'POST') || (read && ['/v1/agent/install.sh', '/v1/agent/uninstall.sh', '/v1/agent/sync-script', '/v1/agent/mikrotik-install.rsc'].includes(path)) if (publicAgent || (read && ['/api/v1/auth/config', '/api/auth/config'].includes(path))) return { method, url, policy: 'public', permission: null } if (path === '/v1' || path.startsWith('/v1/')) throw new Error(`Agent route lacks authentication mapping: ${method} ${path}`) if (path !== '/api' && !path.startsWith('/api/')) return { method, url, policy: 'public', permission: null } if (path === '/api/v1/ui/summary' && ['GET', 'HEAD'].includes(method)) return { method, url, policy: 'section-filtered', permission: null } const permission = permissionForRequest(method, path) if (!permission) throw new Error(`Protected route lacks permission mapping: ${method} ${path}`) return { method, url, policy: 'protected', permission } } export class IdentityLimiter { private buckets = new Map() rejected = 0 constructor(readonly capacity = 10_000) {} take(key: string, limit: number, now = Date.now()): boolean { let value = this.buckets.get(key) if (!value || now - value.start >= 60_000) { if (!value && this.buckets.size >= this.capacity) { for (const [id, bucket] of this.buckets) if (now - bucket.start >= 60_000) this.buckets.delete(id) if (this.buckets.size >= this.capacity) { this.rejected++; return false } } value = { start: now, count: 0 }; this.buckets.set(key, value) } if (value.count >= limit) { this.rejected++; return false } value.count++; return true } stats() { return { buckets: this.buckets.size, capacity: this.capacity, rejected: this.rejected } } available(key: string, limit: number, now = Date.now()): boolean { const bucket = this.buckets.get(key) return !bucket || now - bucket.start >= 60_000 || bucket.count < limit } } const operationalPlugin: FastifyPluginAsync<{ config: AppConfig; memory?: boolean }> = async (app, { config, memory }) => { const limiter = new IdentityLimiter() const failures = new IdentityLimiter() const queue = new AuditOutbox(app.sqlite, config) app.decorate('auditOutbox', queue) app.decorate('routeSecurity', []) app.decorate('allowFailedAuthAttempt', (ip: string) => failures.available(ip, 60)) app.addHook('onResponse', async (request, reply) => { if (reply.statusCode === 401 || reply.statusCode === 403) failures.take(request.ip, 60) }) app.addHook('onRoute', route => { for (const method of Array.isArray(route.method) ? route.method : [route.method]) { const security = routeSecurityFor(method, route.url) app.routeSecurity.push(security) route.config = { ...route.config, permissionPolicy: security.policy, requiredPermission: security.permission } } }) app.addHook('preHandler', async (request, reply) => { const security = request.routeOptions.config if (security.permissionPolicy === 'agent' && !request.agentId) return reply.code(401).send({ error: { code: 'UNAUTHORIZED', message: 'Agent token required' } }) if (security.permissionPolicy === 'protected' || security.permissionPolicy === 'section-filtered') { if (!request.authUser) await app.requireAuth(request, reply) if (reply.sent) return reply if (security.requiredPermission && !request.authUser?.isAdmin && !hasPermission(request.authUser?.permissions || [], security.requiredPermission)) { return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Недостаточно прав', request_id: request.id } }) } } const path = request.url.split('?')[0]! if (path === '/health' || path === '/ready' || (!path.startsWith('/api/') && !path.startsWith('/v1/'))) return const enrollment = path === '/v1/agent/enroll' const key = enrollment ? `enroll:${request.ip}` : request.agentId ? `agent:${request.agentId}` : request.authUser ? `user:${request.authUser.id}` : `public:${request.ip}` const limit = enrollment ? 20 : request.agentId ? 120 : request.authUser ? 600 : 120 if (!limiter.take(key, limit)) return reply.code(429).header('Retry-After', '60').send({ error: { code: 'RATE_LIMITED', message: 'Too many requests', request_id: request.id } }) if (queue.enabled && security.permissionPolicy === 'protected' && !['GET', 'HEAD', 'OPTIONS'].includes(request.method) && queue.size() >= AUDIT_QUEUE_LIMIT) { return reply.code(503).send({ error: { code: 'AUDIT_BACKPRESSURE', message: 'Audit delivery queue is full', request_id: request.id } }) } }) app.get('/api/v1/diagnostics', async () => { const path = config.databaseUrl.replace(/^sqlite:/, '') const fileSize = async (name: string) => { try { return (await stat(name)).size } catch { return null } } let disk: { free_bytes: number; total_bytes: number } | null = null if (!memory) try { const data = await statfs(dirname(path)); disk = { free_bytes: data.bavail * data.bsize, total_bytes: data.blocks * data.bsize } } catch { /* exposed as unavailable */ } return { database: { memory: !!memory, db_bytes: memory ? null : await fileSize(path), wal_bytes: memory ? null : await fileSize(`${path}-wal`), disk }, audit_outbox: queue.stats(), limiter: limiter.stats(), auth_failures: failures.stats(), process: { uptime_seconds: process.uptime(), rss_bytes: process.memoryUsage().rss, heap_used_bytes: process.memoryUsage().heapUsed }, } }) const timer = memory ? null : setInterval(() => { void queue.flush().catch(() => app.log.warn('audit outbox worker failed')) }, 5000) timer?.unref() app.addHook('onClose', async () => { if (timer) clearInterval(timer); await queue.close() }) } export default fp(operationalPlugin, { name: 'operational', dependencies: ['auth', 'db'] })