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.
106 lines
7.0 KiB
TypeScript
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'] })
|