- Introduced an uninstall script for agents, allowing users to easily remove the agent with a single command. - Updated `install.sh` to quote configuration values for safety, ensuring compatibility with names containing spaces. - Enhanced the installation process to include a warning if the uninstall script cannot be downloaded. - Updated documentation to reflect the new uninstall functionality and changes in configuration file handling.
222 lines
5.9 KiB
TypeScript
222 lines
5.9 KiB
TypeScript
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
|
import fp from 'fastify-plugin'
|
|
import {
|
|
hasPermission,
|
|
permissionForRequest,
|
|
type AuthUser,
|
|
} from '@evofw/shared'
|
|
import type { AppConfig } from '../config.js'
|
|
import { createHash } from 'node:crypto'
|
|
import { repos } from '@evofw/db'
|
|
|
|
declare module 'fastify' {
|
|
interface FastifyRequest {
|
|
authUser?: AuthUser
|
|
agentId?: string
|
|
}
|
|
}
|
|
|
|
declare module '@fastify/jwt' {
|
|
interface FastifyJWT {
|
|
payload: {
|
|
sub: string
|
|
email?: string
|
|
name?: string
|
|
apps?: string[]
|
|
permissions?: string[]
|
|
is_admin?: boolean
|
|
iss?: string
|
|
}
|
|
user: {
|
|
sub: string
|
|
email?: string
|
|
name?: string
|
|
apps?: string[]
|
|
permissions?: string[]
|
|
is_admin?: boolean
|
|
iss?: string
|
|
}
|
|
}
|
|
}
|
|
|
|
function hashToken(token: string): string {
|
|
return createHash('sha256').update(token).digest('hex')
|
|
}
|
|
|
|
function isPublicPath(url: string): boolean {
|
|
const path = url.split('?')[0] ?? url
|
|
if (path === '/health' || path === '/ready') return true
|
|
if (path === '/api/auth/config' || path === '/api/v1/auth/config') return true
|
|
if (path.startsWith('/v1/agent/enroll')) return true
|
|
if (path.startsWith('/v1/agent/install')) return true
|
|
if (path.startsWith('/v1/agent/sync-script')) return true
|
|
if (path.startsWith('/v1/agent/uninstall')) return true
|
|
if (path.startsWith('/v1/agent/mikrotik')) return true
|
|
return false
|
|
}
|
|
|
|
function isAgentPath(url: string): boolean {
|
|
const path = url.split('?')[0] ?? url
|
|
return (
|
|
path === '/v1/agent/policy' ||
|
|
path === '/v1/agent/policy.rsc' ||
|
|
path === '/v1/agent/apply-report' ||
|
|
path === '/v1/agent/heartbeat'
|
|
)
|
|
}
|
|
|
|
async function authPlugin(
|
|
app: FastifyInstance,
|
|
opts: { config: AppConfig },
|
|
) {
|
|
const { config } = opts
|
|
|
|
app.get('/api/auth/config', async () => ({
|
|
required: config.authRequired,
|
|
portal_url: config.authPortalUrl,
|
|
}))
|
|
app.get('/api/v1/auth/config', async () => ({
|
|
required: config.authRequired,
|
|
portal_url: config.authPortalUrl,
|
|
}))
|
|
|
|
if (config.authRequired) {
|
|
if (!config.jwtSecret || config.jwtSecret.length < 8) {
|
|
throw new Error(
|
|
'AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true',
|
|
)
|
|
}
|
|
await app.register(import('@fastify/jwt'), {
|
|
secret: config.jwtSecret,
|
|
verify: { allowedIss: [config.authIssuer] },
|
|
})
|
|
} else {
|
|
await app.register(import('@fastify/jwt'), {
|
|
secret: config.jwtSecret || 'dev-secret-change-me',
|
|
})
|
|
}
|
|
|
|
app.decorate(
|
|
'requireAuth',
|
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
if (!config.authRequired) {
|
|
request.authUser = {
|
|
id: 'dev',
|
|
email: 'dev@local',
|
|
name: 'Dev',
|
|
apps: ['fw'],
|
|
permissions: [
|
|
'fw:dashboard:read',
|
|
'fw:agents:write',
|
|
'fw:lists:write',
|
|
'fw:policies:write',
|
|
'fw:stats:read',
|
|
'fw:settings:admin',
|
|
'fw:audit:read',
|
|
],
|
|
isAdmin: true,
|
|
}
|
|
return
|
|
}
|
|
|
|
try {
|
|
await request.jwtVerify()
|
|
} catch {
|
|
return reply.code(401).send({
|
|
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
|
|
})
|
|
}
|
|
|
|
const payload = request.user
|
|
const apps = Array.isArray(payload.apps)
|
|
? payload.apps.map(String)
|
|
: []
|
|
const permissions = Array.isArray(payload.permissions)
|
|
? payload.permissions.map(String)
|
|
: []
|
|
|
|
if (!apps.includes('fw') && !payload.is_admin) {
|
|
return reply.code(403).send({
|
|
error: {
|
|
code: 'FORBIDDEN',
|
|
message: 'Нет доступа к приложению EvoFirewall',
|
|
},
|
|
})
|
|
}
|
|
|
|
request.authUser = {
|
|
id: String(payload.sub),
|
|
email: String(payload.email ?? ''),
|
|
name: String(payload.name ?? ''),
|
|
apps,
|
|
permissions: payload.is_admin
|
|
? [
|
|
'fw:dashboard:read',
|
|
'fw:agents:write',
|
|
'fw:lists:write',
|
|
'fw:policies:write',
|
|
'fw:stats:read',
|
|
'fw:settings:admin',
|
|
'fw:audit:read',
|
|
]
|
|
: permissions,
|
|
isAdmin: Boolean(payload.is_admin),
|
|
}
|
|
|
|
const required = permissionForRequest(request.method, request.url)
|
|
if (
|
|
required &&
|
|
!request.authUser.isAdmin &&
|
|
!hasPermission(request.authUser.permissions, required)
|
|
) {
|
|
return reply.code(403).send({
|
|
error: {
|
|
code: 'FORBIDDEN',
|
|
message: `Недостаточно прав: ${required}`,
|
|
},
|
|
})
|
|
}
|
|
},
|
|
)
|
|
|
|
app.addHook('onRequest', async (request, reply) => {
|
|
if (isPublicPath(request.url)) return
|
|
|
|
if (isAgentPath(request.url)) {
|
|
const auth = request.headers.authorization
|
|
if (!auth?.startsWith('Bearer ')) {
|
|
return reply.code(401).send({
|
|
error: { code: 'UNAUTHORIZED', message: 'Agent token required' },
|
|
})
|
|
}
|
|
const token = auth.slice('Bearer '.length).trim()
|
|
const agent = repos.getAgentByTokenHash(app.db, hashToken(token))
|
|
if (!agent || agent.status !== 'approved') {
|
|
return reply.code(agent?.status === 'pending' ? 403 : 401).send({
|
|
error: {
|
|
code: agent?.status === 'pending' ? 'FORBIDDEN' : 'UNAUTHORIZED',
|
|
message:
|
|
agent?.status === 'pending'
|
|
? 'Agent pending approval'
|
|
: 'Invalid agent token',
|
|
},
|
|
})
|
|
}
|
|
request.agentId = agent.id
|
|
return
|
|
}
|
|
})
|
|
}
|
|
|
|
declare module 'fastify' {
|
|
interface FastifyInstance {
|
|
requireAuth: (
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
) => Promise<void | FastifyReply>
|
|
}
|
|
}
|
|
|
|
export default fp(authPlugin, { name: 'auth' })
|
|
export { hashToken }
|