Files
EvoFirewall/apps/api/src/plugins/operational.ts
T
Denozordec 9fa6269174
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
feat: harden firewall policies telemetry and operations
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.
2026-10-26 13:10:58 +07:00

106 lines
7.0 KiB
TypeScript

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<string, { start: number; count: number }>()
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'] })