first commit

This commit is contained in:
Muhammad Ibrahim
2025-09-16 15:36:42 +01:00
commit c5332ce6b0
61 changed files with 21858 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
const jwt = require('jsonwebtoken');
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
// Middleware to verify JWT token
const authenticateToken = async (req, res, next) => {
try {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
// Get user from database
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: {
id: true,
username: true,
email: true,
role: true,
isActive: true,
lastLogin: true
}
});
if (!user || !user.isActive) {
return res.status(401).json({ error: 'Invalid or inactive user' });
}
// Update last login
await prisma.user.update({
where: { id: user.id },
data: { lastLogin: new Date() }
});
req.user = user;
next();
} catch (error) {
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'Invalid token' });
}
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
console.error('Auth middleware error:', error);
return res.status(500).json({ error: 'Authentication failed' });
}
};
// Middleware to check admin role
const requireAdmin = (req, res, next) => {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
};
// Middleware to check if user is authenticated (optional)
const optionalAuth = async (req, res, next) => {
try {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (token) {
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: {
id: true,
username: true,
email: true,
role: true,
isActive: true
}
});
if (user && user.isActive) {
req.user = user;
}
}
next();
} catch (error) {
// Continue without authentication for optional auth
next();
}
};
module.exports = {
authenticateToken,
requireAdmin,
optionalAuth
};

View File

@@ -0,0 +1,59 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
// Permission middleware factory
const requirePermission = (permission) => {
return async (req, res, next) => {
try {
// Get user's role permissions
const rolePermissions = await prisma.rolePermissions.findUnique({
where: { role: req.user.role }
});
// If no specific permissions found, default to admin permissions (for backward compatibility)
if (!rolePermissions) {
console.warn(`No permissions found for role: ${req.user.role}, defaulting to admin access`);
return next();
}
// Check if user has the required permission
if (!rolePermissions[permission]) {
return res.status(403).json({
error: 'Insufficient permissions',
message: `You don't have permission to ${permission.replace('can', '').toLowerCase()}`
});
}
next();
} catch (error) {
console.error('Permission check error:', error);
res.status(500).json({ error: 'Permission check failed' });
}
};
};
// Specific permission middlewares
const requireViewDashboard = requirePermission('canViewDashboard');
const requireViewHosts = requirePermission('canViewHosts');
const requireManageHosts = requirePermission('canManageHosts');
const requireViewPackages = requirePermission('canViewPackages');
const requireManagePackages = requirePermission('canManagePackages');
const requireViewUsers = requirePermission('canViewUsers');
const requireManageUsers = requirePermission('canManageUsers');
const requireViewReports = requirePermission('canViewReports');
const requireExportData = requirePermission('canExportData');
const requireManageSettings = requirePermission('canManageSettings');
module.exports = {
requirePermission,
requireViewDashboard,
requireViewHosts,
requireManageHosts,
requireViewPackages,
requireManagePackages,
requireViewUsers,
requireManageUsers,
requireViewReports,
requireExportData,
requireManageSettings
};

View File

@@ -0,0 +1,452 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { PrismaClient } = require('@prisma/client');
const { body, validationResult } = require('express-validator');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { requireViewUsers, requireManageUsers } = require('../middleware/permissions');
const router = express.Router();
const prisma = new PrismaClient();
// Generate JWT token
const generateToken = (userId) => {
return jwt.sign(
{ userId },
process.env.JWT_SECRET || 'your-secret-key',
{ expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
);
};
// Admin endpoint to list all users
router.get('/admin/users', authenticateToken, requireViewUsers, async (req, res) => {
try {
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
role: true,
isActive: true,
lastLogin: true,
createdAt: true,
updatedAt: true
},
orderBy: {
createdAt: 'desc'
}
})
res.json(users)
} catch (error) {
console.error('List users error:', error)
res.status(500).json({ error: 'Failed to fetch users' })
}
})
// Admin endpoint to create a new user
router.post('/admin/users', authenticateToken, requireManageUsers, [
body('username').isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
body('email').isEmail().withMessage('Valid email is required'),
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'),
body('role').optional().custom(async (value) => {
if (!value) return true; // Optional field
const rolePermissions = await prisma.rolePermissions.findUnique({
where: { role: value }
});
if (!rolePermissions) {
throw new Error('Invalid role specified');
}
return true;
})
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, email, password, role = 'user' } = req.body;
// Check if user already exists
const existingUser = await prisma.user.findFirst({
where: {
OR: [
{ username },
{ email }
]
}
});
if (existingUser) {
return res.status(409).json({ error: 'Username or email already exists' });
}
// Hash password
const passwordHash = await bcrypt.hash(password, 12);
// Create user
const user = await prisma.user.create({
data: {
username,
email,
passwordHash,
role
},
select: {
id: true,
username: true,
email: true,
role: true,
isActive: true,
createdAt: true
}
});
res.status(201).json({
message: 'User created successfully',
user
});
} catch (error) {
console.error('User creation error:', error);
res.status(500).json({ error: 'Failed to create user' });
}
});
// Admin endpoint to update a user
router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
body('username').optional().isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
body('email').optional().isEmail().withMessage('Valid email is required'),
body('role').optional().custom(async (value) => {
if (!value) return true; // Optional field
const rolePermissions = await prisma.rolePermissions.findUnique({
where: { role: value }
});
if (!rolePermissions) {
throw new Error('Invalid role specified');
}
return true;
}),
body('isActive').optional().isBoolean().withMessage('isActive must be a boolean')
], async (req, res) => {
try {
const { userId } = req.params;
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, email, role, isActive } = req.body;
const updateData = {};
if (username) updateData.username = username;
if (email) updateData.email = email;
if (role) updateData.role = role;
if (typeof isActive === 'boolean') updateData.isActive = isActive;
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { id: userId }
});
if (!existingUser) {
return res.status(404).json({ error: 'User not found' });
}
// Check if username/email already exists (excluding current user)
if (username || email) {
const duplicateUser = await prisma.user.findFirst({
where: {
AND: [
{ id: { not: userId } },
{
OR: [
...(username ? [{ username }] : []),
...(email ? [{ email }] : [])
]
}
]
}
});
if (duplicateUser) {
return res.status(409).json({ error: 'Username or email already exists' });
}
}
// Prevent deactivating the last admin
if (isActive === false && existingUser.role === 'admin') {
const adminCount = await prisma.user.count({
where: {
role: 'admin',
isActive: true
}
});
if (adminCount <= 1) {
return res.status(400).json({ error: 'Cannot deactivate the last admin user' });
}
}
// Update user
const updatedUser = await prisma.user.update({
where: { id: userId },
data: updateData,
select: {
id: true,
username: true,
email: true,
role: true,
isActive: true,
lastLogin: true,
createdAt: true,
updatedAt: true
}
});
res.json({
message: 'User updated successfully',
user: updatedUser
});
} catch (error) {
console.error('User update error:', error);
res.status(500).json({ error: 'Failed to update user' });
}
});
// Admin endpoint to delete a user
router.delete('/admin/users/:userId', authenticateToken, requireManageUsers, async (req, res) => {
try {
const { userId } = req.params;
// Prevent self-deletion
if (userId === req.user.id) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
// Check if user exists
const user = await prisma.user.findUnique({
where: { id: userId }
});
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Prevent deleting the last admin
if (user.role === 'admin') {
const adminCount = await prisma.user.count({
where: {
role: 'admin',
isActive: true
}
});
if (adminCount <= 1) {
return res.status(400).json({ error: 'Cannot delete the last admin user' });
}
}
// Delete user
await prisma.user.delete({
where: { id: userId }
});
res.json({
message: 'User deleted successfully'
});
} catch (error) {
console.error('User deletion error:', error);
res.status(500).json({ error: 'Failed to delete user' });
}
});
// Login
router.post('/login', [
body('username').notEmpty().withMessage('Username is required'),
body('password').notEmpty().withMessage('Password is required')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, password } = req.body;
// Find user by username or email
const user = await prisma.user.findFirst({
where: {
OR: [
{ username },
{ email: username }
],
isActive: true
}
});
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Update last login
await prisma.user.update({
where: { id: user.id },
data: { lastLogin: new Date() }
});
// Generate token
const token = generateToken(user.id);
res.json({
message: 'Login successful',
token,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
// Get current user profile
router.get('/profile', authenticateToken, async (req, res) => {
try {
res.json({
user: req.user
});
} catch (error) {
console.error('Get profile error:', error);
res.status(500).json({ error: 'Failed to get profile' });
}
});
// Update user profile
router.put('/profile', authenticateToken, [
body('username').optional().isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
body('email').optional().isEmail().withMessage('Valid email is required')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, email } = req.body;
const updateData = {};
if (username) updateData.username = username;
if (email) updateData.email = email;
// Check if username/email already exists (excluding current user)
if (username || email) {
const existingUser = await prisma.user.findFirst({
where: {
AND: [
{ id: { not: req.user.id } },
{
OR: [
...(username ? [{ username }] : []),
...(email ? [{ email }] : [])
]
}
]
}
});
if (existingUser) {
return res.status(409).json({ error: 'Username or email already exists' });
}
}
const updatedUser = await prisma.user.update({
where: { id: req.user.id },
data: updateData,
select: {
id: true,
username: true,
email: true,
role: true,
isActive: true,
lastLogin: true,
updatedAt: true
}
});
res.json({
message: 'Profile updated successfully',
user: updatedUser
});
} catch (error) {
console.error('Update profile error:', error);
res.status(500).json({ error: 'Failed to update profile' });
}
});
// Change password
router.put('/change-password', authenticateToken, [
body('currentPassword').notEmpty().withMessage('Current password is required'),
body('newPassword').isLength({ min: 6 }).withMessage('New password must be at least 6 characters')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { currentPassword, newPassword } = req.body;
// Get user with password hash
const user = await prisma.user.findUnique({
where: { id: req.user.id }
});
// Verify current password
const isValidPassword = await bcrypt.compare(currentPassword, user.passwordHash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
// Hash new password
const newPasswordHash = await bcrypt.hash(newPassword, 12);
// Update password
await prisma.user.update({
where: { id: req.user.id },
data: { passwordHash: newPasswordHash }
});
res.json({
message: 'Password changed successfully'
});
} catch (error) {
console.error('Change password error:', error);
res.status(500).json({ error: 'Failed to change password' });
}
});
// Logout (client-side token removal)
router.post('/logout', authenticateToken, async (req, res) => {
try {
res.json({
message: 'Logout successful'
});
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
module.exports = router;

View File

@@ -0,0 +1,89 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { PrismaClient } = require('@prisma/client');
const { authenticateToken } = require('../middleware/auth');
const router = express.Router();
const prisma = new PrismaClient();
// Get user's dashboard preferences
router.get('/', authenticateToken, async (req, res) => {
try {
const preferences = await prisma.dashboardPreferences.findMany({
where: { userId: req.user.id },
orderBy: { order: 'asc' }
});
res.json(preferences);
} catch (error) {
console.error('Dashboard preferences fetch error:', error);
res.status(500).json({ error: 'Failed to fetch dashboard preferences' });
}
});
// Update dashboard preferences (bulk update)
router.put('/', authenticateToken, [
body('preferences').isArray().withMessage('Preferences must be an array'),
body('preferences.*.cardId').isString().withMessage('Card ID is required'),
body('preferences.*.enabled').isBoolean().withMessage('Enabled must be boolean'),
body('preferences.*.order').isInt().withMessage('Order must be integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { preferences } = req.body;
const userId = req.user.id;
// Delete existing preferences for this user
await prisma.dashboardPreferences.deleteMany({
where: { userId }
});
// Create new preferences
const newPreferences = preferences.map(pref => ({
userId,
cardId: pref.cardId,
enabled: pref.enabled,
order: pref.order
}));
const createdPreferences = await prisma.dashboardPreferences.createMany({
data: newPreferences
});
res.json({
message: 'Dashboard preferences updated successfully',
preferences: newPreferences
});
} catch (error) {
console.error('Dashboard preferences update error:', error);
res.status(500).json({ error: 'Failed to update dashboard preferences' });
}
});
// Get default dashboard card configuration
router.get('/defaults', authenticateToken, async (req, res) => {
try {
const defaultCards = [
{ cardId: 'totalHosts', title: 'Total Hosts', icon: 'Server', enabled: true, order: 0 },
{ cardId: 'hostsNeedingUpdates', title: 'Needs Updating', icon: 'AlertTriangle', enabled: true, order: 1 },
{ cardId: 'totalOutdatedPackages', title: 'Outdated Packages', icon: 'Package', enabled: true, order: 2 },
{ cardId: 'securityUpdates', title: 'Security Updates', icon: 'Shield', enabled: true, order: 3 },
{ cardId: 'erroredHosts', title: 'Errored Hosts', icon: 'AlertTriangle', enabled: true, order: 4 },
{ cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 5 },
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 6 },
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 7 },
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 8 }
];
res.json(defaultCards);
} catch (error) {
console.error('Default dashboard cards error:', error);
res.status(500).json({ error: 'Failed to fetch default dashboard cards' });
}
});
module.exports = router;

View File

@@ -0,0 +1,313 @@
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const moment = require('moment');
const { authenticateToken } = require('../middleware/auth');
const {
requireViewDashboard,
requireViewHosts,
requireViewPackages
} = require('../middleware/permissions');
const router = express.Router();
const prisma = new PrismaClient();
// Get dashboard statistics
router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) => {
try {
const now = new Date();
const twentyFourHoursAgo = moment(now).subtract(24, 'hours').toDate();
// Get all statistics in parallel for better performance
const [
totalHosts,
hostsNeedingUpdates,
totalOutdatedPackages,
erroredHosts,
securityUpdates,
osDistribution,
updateTrends
] = await Promise.all([
// Total hosts count
prisma.host.count({
where: { status: 'active' }
}),
// Hosts needing updates (distinct hosts with packages needing updates)
prisma.host.count({
where: {
status: 'active',
hostPackages: {
some: {
needsUpdate: true
}
}
}
}),
// Total outdated packages across all hosts
prisma.hostPackage.count({
where: { needsUpdate: true }
}),
// Errored hosts (not updated in 24 hours)
prisma.host.count({
where: {
status: 'active',
lastUpdate: {
lt: twentyFourHoursAgo
}
}
}),
// Security updates count
prisma.hostPackage.count({
where: {
needsUpdate: true,
isSecurityUpdate: true
}
}),
// OS distribution for pie chart
prisma.host.groupBy({
by: ['osType'],
where: { status: 'active' },
_count: {
osType: true
}
}),
// Update trends for the last 7 days
prisma.updateHistory.groupBy({
by: ['timestamp'],
where: {
timestamp: {
gte: moment(now).subtract(7, 'days').toDate()
}
},
_count: {
id: true
},
_sum: {
packagesCount: true,
securityCount: true
}
})
]);
// Format OS distribution for pie chart
const osDistributionFormatted = osDistribution.map(item => ({
name: item.osType,
count: item._count.osType
}));
// Calculate update status distribution
const updateStatusDistribution = [
{ name: 'Up to date', count: totalHosts - hostsNeedingUpdates },
{ name: 'Needs updates', count: hostsNeedingUpdates },
{ name: 'Errored', count: erroredHosts }
];
// Package update priority distribution
const packageUpdateDistribution = [
{ name: 'Security', count: securityUpdates },
{ name: 'Regular', count: totalOutdatedPackages - securityUpdates }
];
res.json({
cards: {
totalHosts,
hostsNeedingUpdates,
totalOutdatedPackages,
erroredHosts,
securityUpdates
},
charts: {
osDistribution: osDistributionFormatted,
updateStatusDistribution,
packageUpdateDistribution
},
trends: updateTrends,
lastUpdated: now.toISOString()
});
} catch (error) {
console.error('Error fetching dashboard stats:', error);
res.status(500).json({ error: 'Failed to fetch dashboard statistics' });
}
});
// Get hosts with their update status
router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
try {
const hosts = await prisma.host.findMany({
// Show all hosts regardless of status
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
lastUpdate: true,
status: true,
agentVersion: true,
autoUpdate: true,
hostGroup: {
select: {
id: true,
name: true,
color: true
}
},
_count: {
select: {
hostPackages: {
where: {
needsUpdate: true
}
}
}
}
},
orderBy: { lastUpdate: 'desc' }
});
// Get update counts for each host separately
const hostsWithUpdateInfo = await Promise.all(
hosts.map(async (host) => {
const updatesCount = await prisma.hostPackage.count({
where: {
hostId: host.id,
needsUpdate: true
}
});
return {
...host,
updatesCount,
isStale: moment(host.lastUpdate).isBefore(moment().subtract(24, 'hours'))
};
})
);
res.json(hostsWithUpdateInfo);
} catch (error) {
console.error('Error fetching hosts:', error);
res.status(500).json({ error: 'Failed to fetch hosts' });
}
});
// Get packages that need updates across all hosts
router.get('/packages', authenticateToken, requireViewPackages, async (req, res) => {
try {
const packages = await prisma.package.findMany({
where: {
hostPackages: {
some: {
needsUpdate: true
}
}
},
select: {
id: true,
name: true,
description: true,
category: true,
latestVersion: true,
hostPackages: {
where: { needsUpdate: true },
select: {
currentVersion: true,
availableVersion: true,
isSecurityUpdate: true,
host: {
select: {
id: true,
hostname: true,
osType: true
}
}
}
}
},
orderBy: {
name: 'asc'
}
});
const packagesWithHostInfo = packages.map(pkg => ({
id: pkg.id,
name: pkg.name,
description: pkg.description,
category: pkg.category,
latestVersion: pkg.latestVersion,
affectedHostsCount: pkg.hostPackages.length,
isSecurityUpdate: pkg.hostPackages.some(hp => hp.isSecurityUpdate),
affectedHosts: pkg.hostPackages.map(hp => ({
hostId: hp.host.id,
hostname: hp.host.hostname,
osType: hp.host.osType,
currentVersion: hp.currentVersion,
availableVersion: hp.availableVersion,
isSecurityUpdate: hp.isSecurityUpdate
}))
}));
res.json(packagesWithHostInfo);
} catch (error) {
console.error('Error fetching packages:', error);
res.status(500).json({ error: 'Failed to fetch packages' });
}
});
// Get detailed host information
router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, res) => {
try {
const { hostId } = req.params;
const host = await prisma.host.findUnique({
where: { id: hostId },
include: {
hostGroup: {
select: {
id: true,
name: true,
color: true
}
},
hostPackages: {
include: {
package: true
},
orderBy: {
needsUpdate: 'desc'
}
},
updateHistory: {
orderBy: {
timestamp: 'desc'
},
take: 10
}
}
});
if (!host) {
return res.status(404).json({ error: 'Host not found' });
}
const hostWithStats = {
...host,
stats: {
totalPackages: host.hostPackages.length,
outdatedPackages: host.hostPackages.filter(hp => hp.needsUpdate).length,
securityUpdates: host.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length
}
};
res.json(hostWithStats);
} catch (error) {
console.error('Error fetching host details:', error);
res.status(500).json({ error: 'Failed to fetch host details' });
}
});
module.exports = router;

View File

@@ -0,0 +1,225 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { PrismaClient } = require('@prisma/client');
const { authenticateToken } = require('../middleware/auth');
const { requireManageHosts } = require('../middleware/permissions');
const router = express.Router();
const prisma = new PrismaClient();
// Get all host groups
router.get('/', authenticateToken, async (req, res) => {
try {
const hostGroups = await prisma.hostGroup.findMany({
include: {
_count: {
select: {
hosts: true
}
}
},
orderBy: {
name: 'asc'
}
});
res.json(hostGroups);
} catch (error) {
console.error('Error fetching host groups:', error);
res.status(500).json({ error: 'Failed to fetch host groups' });
}
});
// Get a specific host group by ID
router.get('/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const hostGroup = await prisma.hostGroup.findUnique({
where: { id },
include: {
hosts: {
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
status: true,
lastUpdate: true
}
}
}
});
if (!hostGroup) {
return res.status(404).json({ error: 'Host group not found' });
}
res.json(hostGroup);
} catch (error) {
console.error('Error fetching host group:', error);
res.status(500).json({ error: 'Failed to fetch host group' });
}
});
// Create a new host group
router.post('/', authenticateToken, requireManageHosts, [
body('name').trim().isLength({ min: 1 }).withMessage('Name is required'),
body('description').optional().trim(),
body('color').optional().isHexColor().withMessage('Color must be a valid hex color')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { name, description, color } = req.body;
// Check if host group with this name already exists
const existingGroup = await prisma.hostGroup.findUnique({
where: { name }
});
if (existingGroup) {
return res.status(400).json({ error: 'A host group with this name already exists' });
}
const hostGroup = await prisma.hostGroup.create({
data: {
name,
description: description || null,
color: color || '#3B82F6'
}
});
res.status(201).json(hostGroup);
} catch (error) {
console.error('Error creating host group:', error);
res.status(500).json({ error: 'Failed to create host group' });
}
});
// Update a host group
router.put('/:id', authenticateToken, requireManageHosts, [
body('name').trim().isLength({ min: 1 }).withMessage('Name is required'),
body('description').optional().trim(),
body('color').optional().isHexColor().withMessage('Color must be a valid hex color')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { name, description, color } = req.body;
// Check if host group exists
const existingGroup = await prisma.hostGroup.findUnique({
where: { id }
});
if (!existingGroup) {
return res.status(404).json({ error: 'Host group not found' });
}
// Check if another host group with this name already exists
const duplicateGroup = await prisma.hostGroup.findFirst({
where: {
name,
id: { not: id }
}
});
if (duplicateGroup) {
return res.status(400).json({ error: 'A host group with this name already exists' });
}
const hostGroup = await prisma.hostGroup.update({
where: { id },
data: {
name,
description: description || null,
color: color || '#3B82F6'
}
});
res.json(hostGroup);
} catch (error) {
console.error('Error updating host group:', error);
res.status(500).json({ error: 'Failed to update host group' });
}
});
// Delete a host group
router.delete('/:id', authenticateToken, requireManageHosts, async (req, res) => {
try {
const { id } = req.params;
// Check if host group exists
const existingGroup = await prisma.hostGroup.findUnique({
where: { id },
include: {
_count: {
select: {
hosts: true
}
}
}
});
if (!existingGroup) {
return res.status(404).json({ error: 'Host group not found' });
}
// Check if host group has hosts
if (existingGroup._count.hosts > 0) {
return res.status(400).json({
error: 'Cannot delete host group that contains hosts. Please move or remove hosts first.'
});
}
await prisma.hostGroup.delete({
where: { id }
});
res.json({ message: 'Host group deleted successfully' });
} catch (error) {
console.error('Error deleting host group:', error);
res.status(500).json({ error: 'Failed to delete host group' });
}
});
// Get hosts in a specific group
router.get('/:id/hosts', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const hosts = await prisma.host.findMany({
where: { hostGroupId: id },
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
architecture: true,
status: true,
lastUpdate: true,
createdAt: true
},
orderBy: {
hostname: 'asc'
}
});
res.json(hosts);
} catch (error) {
console.error('Error fetching hosts in group:', error);
res.status(500).json({ error: 'Failed to fetch hosts in group' });
}
});
module.exports = router;

View File

@@ -0,0 +1,928 @@
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { body, validationResult } = require('express-validator');
const { v4: uuidv4 } = require('uuid');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { requireManageHosts, requireManageSettings } = require('../middleware/permissions');
const router = express.Router();
const prisma = new PrismaClient();
// Public endpoint to download the agent script
router.get('/agent/download', async (req, res) => {
try {
const { version } = req.query;
let agentVersion;
if (version) {
// Download specific version
agentVersion = await prisma.agentVersion.findUnique({
where: { version }
});
if (!agentVersion) {
return res.status(404).json({ error: 'Agent version not found' });
}
} else {
// Download current version (latest)
agentVersion = await prisma.agentVersion.findFirst({
where: { isCurrent: true },
orderBy: { createdAt: 'desc' }
});
if (!agentVersion) {
// Fallback to default version
agentVersion = await prisma.agentVersion.findFirst({
where: { isDefault: true },
orderBy: { createdAt: 'desc' }
});
}
}
if (!agentVersion) {
return res.status(404).json({ error: 'No agent version available' });
}
// Use script content from database if available, otherwise fallback to file
if (agentVersion.scriptContent) {
res.setHeader('Content-Type', 'application/x-shellscript');
res.setHeader('Content-Disposition', `attachment; filename="patchmon-agent-${agentVersion.version}.sh"`);
res.send(agentVersion.scriptContent);
} else {
// Fallback to file system
const agentPath = path.join(__dirname, '../../../agents/patchmon-agent.sh');
if (!fs.existsSync(agentPath)) {
return res.status(404).json({ error: 'Agent script not found' });
}
res.setHeader('Content-Type', 'application/x-shellscript');
res.setHeader('Content-Disposition', `attachment; filename="patchmon-agent-${agentVersion.version}.sh"`);
res.sendFile(path.resolve(agentPath));
}
} catch (error) {
console.error('Agent download error:', error);
res.status(500).json({ error: 'Failed to download agent script' });
}
});
// Version check endpoint for agents
router.get('/agent/version', async (req, res) => {
try {
const currentVersion = await prisma.agentVersion.findFirst({
where: { isCurrent: true },
orderBy: { createdAt: 'desc' }
});
if (!currentVersion) {
return res.status(404).json({ error: 'No current agent version found' });
}
res.json({
currentVersion: currentVersion.version,
downloadUrl: currentVersion.downloadUrl || `/api/v1/hosts/agent/download`,
releaseNotes: currentVersion.releaseNotes,
minServerVersion: currentVersion.minServerVersion
});
} catch (error) {
console.error('Version check error:', error);
res.status(500).json({ error: 'Failed to get agent version' });
}
});
// Generate API credentials
const generateApiCredentials = () => {
const apiId = `patchmon_${crypto.randomBytes(8).toString('hex')}`;
const apiKey = crypto.randomBytes(32).toString('hex');
return { apiId, apiKey };
};
// Middleware to validate API credentials
const validateApiCredentials = async (req, res, next) => {
try {
const apiId = req.headers['x-api-id'] || req.body.apiId;
const apiKey = req.headers['x-api-key'] || req.body.apiKey;
if (!apiId || !apiKey) {
return res.status(401).json({ error: 'API ID and Key required' });
}
const host = await prisma.host.findFirst({
where: {
apiId: apiId,
apiKey: apiKey
}
});
if (!host) {
return res.status(401).json({ error: 'Invalid API credentials' });
}
req.hostRecord = host;
next();
} catch (error) {
console.error('API credential validation error:', error);
res.status(500).json({ error: 'API credential validation failed' });
}
};
// Admin endpoint to create a new host manually (replaces auto-registration)
router.post('/create', authenticateToken, requireManageHosts, [
body('hostname').isLength({ min: 1 }).withMessage('Hostname is required'),
body('hostGroupId').optional()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { hostname, hostGroupId } = req.body;
// Generate unique API credentials for this host
const { apiId, apiKey } = generateApiCredentials();
// Check if host already exists
const existingHost = await prisma.host.findUnique({
where: { hostname }
});
if (existingHost) {
return res.status(409).json({ error: 'Host already exists' });
}
// If hostGroupId is provided, verify the group exists
if (hostGroupId) {
const hostGroup = await prisma.hostGroup.findUnique({
where: { id: hostGroupId }
});
if (!hostGroup) {
return res.status(400).json({ error: 'Host group not found' });
}
}
// Create new host with API credentials - system info will be populated when agent connects
const host = await prisma.host.create({
data: {
hostname,
osType: 'unknown', // Will be updated when agent connects
osVersion: 'unknown', // Will be updated when agent connects
ip: null, // Will be updated when agent connects
architecture: null, // Will be updated when agent connects
apiId,
apiKey,
hostGroupId: hostGroupId || null,
status: 'pending' // Will change to 'active' when agent connects
},
include: {
hostGroup: {
select: {
id: true,
name: true,
color: true
}
}
}
});
res.status(201).json({
message: 'Host created successfully',
hostId: host.id,
hostname: host.hostname,
apiId: host.apiId,
apiKey: host.apiKey,
hostGroup: host.hostGroup,
instructions: 'Use these credentials in your patchmon agent configuration. System information will be automatically detected when the agent connects.'
});
} catch (error) {
console.error('Host creation error:', error);
res.status(500).json({ error: 'Failed to create host' });
}
});
// Legacy register endpoint (deprecated - returns error message)
router.post('/register', async (req, res) => {
res.status(400).json({
error: 'Host registration has been disabled. Please contact your administrator to add this host to PatchMon.',
deprecated: true,
message: 'Hosts must now be pre-created by administrators with specific API credentials.'
});
});
// Update host information and packages (now uses API credentials)
router.post('/update', validateApiCredentials, [
body('packages').isArray().withMessage('Packages must be an array'),
body('packages.*.name').isLength({ min: 1 }).withMessage('Package name is required'),
body('packages.*.currentVersion').isLength({ min: 1 }).withMessage('Current version is required'),
body('packages.*.availableVersion').optional().isLength({ min: 1 }),
body('packages.*.needsUpdate').isBoolean().withMessage('needsUpdate must be boolean'),
body('packages.*.isSecurityUpdate').optional().isBoolean().withMessage('isSecurityUpdate must be boolean'),
body('agentVersion').optional().isLength({ min: 1 }).withMessage('Agent version must be a non-empty string')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { packages, repositories } = req.body;
const host = req.hostRecord;
// Update host last update timestamp and OS info if provided
const updateData = { lastUpdate: new Date() };
if (req.body.osType) updateData.osType = req.body.osType;
if (req.body.osVersion) updateData.osVersion = req.body.osVersion;
if (req.body.ip) updateData.ip = req.body.ip;
if (req.body.architecture) updateData.architecture = req.body.architecture;
if (req.body.agentVersion) updateData.agentVersion = req.body.agentVersion;
// If this is the first update (status is 'pending'), change to 'active'
if (host.status === 'pending') {
updateData.status = 'active';
}
await prisma.host.update({
where: { id: host.id },
data: updateData
});
// Process packages in transaction
await prisma.$transaction(async (tx) => {
// Clear existing host packages
await tx.hostPackage.deleteMany({
where: { hostId: host.id }
});
// Process each package
for (const packageData of packages) {
// Find or create package
let pkg = await tx.package.findUnique({
where: { name: packageData.name }
});
if (!pkg) {
pkg = await tx.package.create({
data: {
name: packageData.name,
description: packageData.description || null,
category: packageData.category || null,
latestVersion: packageData.availableVersion || packageData.currentVersion
}
});
} else {
// Update package latest version if newer
if (packageData.availableVersion && packageData.availableVersion !== pkg.latestVersion) {
await tx.package.update({
where: { id: pkg.id },
data: { latestVersion: packageData.availableVersion }
});
}
}
// Create host package relationship
await tx.hostPackage.create({
data: {
hostId: host.id,
packageId: pkg.id,
currentVersion: packageData.currentVersion,
availableVersion: packageData.availableVersion || null,
needsUpdate: packageData.needsUpdate,
isSecurityUpdate: packageData.isSecurityUpdate || false,
lastChecked: new Date()
}
});
}
// Process repositories if provided
if (repositories && Array.isArray(repositories)) {
// Clear existing host repositories
await tx.hostRepository.deleteMany({
where: { hostId: host.id }
});
// Process each repository
for (const repoData of repositories) {
// Find or create repository
let repo = await tx.repository.findFirst({
where: {
url: repoData.url,
distribution: repoData.distribution,
components: repoData.components
}
});
if (!repo) {
repo = await tx.repository.create({
data: {
name: repoData.name,
url: repoData.url,
distribution: repoData.distribution,
components: repoData.components,
repoType: repoData.repoType,
isActive: true,
isSecure: repoData.isSecure || false,
description: `${repoData.repoType} repository for ${repoData.distribution}`
}
});
}
// Create host repository relationship
await tx.hostRepository.create({
data: {
hostId: host.id,
repositoryId: repo.id,
isEnabled: repoData.isEnabled !== false, // Default to enabled
lastChecked: new Date()
}
});
}
}
});
// Create update history record
const securityCount = packages.filter(pkg => pkg.isSecurityUpdate).length;
const updatesCount = packages.filter(pkg => pkg.needsUpdate).length;
await prisma.updateHistory.create({
data: {
hostId: host.id,
packagesCount: updatesCount,
securityCount,
status: 'success'
}
});
// Check if auto-update is enabled and if there's a newer agent version available
let autoUpdateResponse = null;
try {
const settings = await prisma.settings.findFirst();
// Check both global auto-update setting AND host-specific auto-update setting
if (settings && settings.autoUpdate && host.autoUpdate) {
// Get current agent version from the request
const currentAgentVersion = req.body.agentVersion;
if (currentAgentVersion) {
// Get the latest agent version
const latestAgentVersion = await prisma.agentVersion.findFirst({
where: { isCurrent: true },
orderBy: { createdAt: 'desc' }
});
if (latestAgentVersion && latestAgentVersion.version !== currentAgentVersion) {
// There's a newer version available
autoUpdateResponse = {
shouldUpdate: true,
currentVersion: currentAgentVersion,
latestVersion: latestAgentVersion.version,
message: 'A newer agent version is available. Run: /usr/local/bin/patchmon-agent.sh update-agent',
updateCommand: 'update-agent'
};
}
}
}
} catch (error) {
console.error('Auto-update check error:', error);
// Don't fail the update if auto-update check fails
}
const response = {
message: 'Host updated successfully',
packagesProcessed: packages.length,
updatesAvailable: updatesCount,
securityUpdates: securityCount
};
// Add auto-update response if available
if (autoUpdateResponse) {
response.autoUpdate = autoUpdateResponse;
}
// Check if crontab update is needed (when update interval changes)
// This is a simple check - if the host has auto-update enabled, we'll suggest crontab update
if (host.autoUpdate) {
// For now, we'll always suggest crontab update to ensure it's current
// In a more sophisticated implementation, we could track when the interval last changed
response.crontabUpdate = {
shouldUpdate: true,
message: 'Please ensure your crontab is up to date with current interval settings',
command: 'update-crontab'
};
}
res.json(response);
} catch (error) {
console.error('Host update error:', error);
// Log error in update history
try {
await prisma.updateHistory.create({
data: {
hostId: req.hostRecord.id,
packagesCount: 0,
securityCount: 0,
status: 'error',
errorMessage: error.message
}
});
} catch (logError) {
console.error('Failed to log update error:', logError);
}
res.status(500).json({ error: 'Failed to update host' });
}
});
// Get host information (now uses API credentials)
router.get('/info', validateApiCredentials, async (req, res) => {
try {
const host = await prisma.host.findUnique({
where: { id: req.hostRecord.id },
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
architecture: true,
lastUpdate: true,
status: true,
createdAt: true,
apiId: true // Include API ID for reference
}
});
res.json(host);
} catch (error) {
console.error('Get host info error:', error);
res.status(500).json({ error: 'Failed to fetch host information' });
}
});
// Ping endpoint for health checks (now uses API credentials)
router.post('/ping', validateApiCredentials, async (req, res) => {
try {
// Update last update timestamp
await prisma.host.update({
where: { id: req.hostRecord.id },
data: { lastUpdate: new Date() }
});
const response = {
message: 'Ping successful',
timestamp: new Date().toISOString(),
hostname: req.hostRecord.hostname
};
// Check if this is a crontab update trigger
if (req.body.triggerCrontabUpdate && req.hostRecord.autoUpdate) {
console.log(`Triggering crontab update for host: ${req.hostRecord.hostname}`);
response.crontabUpdate = {
shouldUpdate: true,
message: 'Update interval changed, please run: /usr/local/bin/patchmon-agent.sh update-crontab',
command: 'update-crontab'
};
}
res.json(response);
} catch (error) {
console.error('Ping error:', error);
res.status(500).json({ error: 'Ping failed' });
}
});
// Admin endpoint to regenerate API credentials for a host
router.post('/:hostId/regenerate-credentials', authenticateToken, requireManageHosts, async (req, res) => {
try {
const { hostId } = req.params;
const host = await prisma.host.findUnique({
where: { id: hostId }
});
if (!host) {
return res.status(404).json({ error: 'Host not found' });
}
// Generate new API credentials
const { apiId, apiKey } = generateApiCredentials();
// Update host with new credentials
const updatedHost = await prisma.host.update({
where: { id: hostId },
data: { apiId, apiKey }
});
res.json({
message: 'API credentials regenerated successfully',
hostname: updatedHost.hostname,
apiId: updatedHost.apiId,
apiKey: updatedHost.apiKey,
warning: 'Previous credentials are now invalid. Update your agent configuration.'
});
} catch (error) {
console.error('Credential regeneration error:', error);
res.status(500).json({ error: 'Failed to regenerate credentials' });
}
});
// Admin endpoint to bulk update host groups
router.put('/bulk/group', authenticateToken, requireManageHosts, [
body('hostIds').isArray().withMessage('Host IDs must be an array'),
body('hostIds.*').isLength({ min: 1 }).withMessage('Each host ID must be provided'),
body('hostGroupId').optional()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { hostIds, hostGroupId } = req.body;
// If hostGroupId is provided, verify the group exists
if (hostGroupId) {
const hostGroup = await prisma.hostGroup.findUnique({
where: { id: hostGroupId }
});
if (!hostGroup) {
return res.status(400).json({ error: 'Host group not found' });
}
}
// Check if all hosts exist
const existingHosts = await prisma.host.findMany({
where: { id: { in: hostIds } },
select: { id: true, hostname: true }
});
if (existingHosts.length !== hostIds.length) {
const foundIds = existingHosts.map(h => h.id);
const missingIds = hostIds.filter(id => !foundIds.includes(id));
return res.status(400).json({
error: 'Some hosts not found',
missingHostIds: missingIds
});
}
// Bulk update host groups
const updateResult = await prisma.host.updateMany({
where: { id: { in: hostIds } },
data: {
hostGroupId: hostGroupId || null
}
});
// Get updated hosts with group information
const updatedHosts = await prisma.host.findMany({
where: { id: { in: hostIds } },
select: {
id: true,
hostname: true,
hostGroup: {
select: {
id: true,
name: true,
color: true
}
}
}
});
res.json({
message: `Successfully updated ${updateResult.count} host${updateResult.count !== 1 ? 's' : ''}`,
updatedCount: updateResult.count,
hosts: updatedHosts
});
} catch (error) {
console.error('Bulk host group update error:', error);
res.status(500).json({ error: 'Failed to update host groups' });
}
});
// Admin endpoint to update host group
router.put('/:hostId/group', authenticateToken, requireManageHosts, [
body('hostGroupId').optional()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { hostId } = req.params;
const { hostGroupId } = req.body;
// Check if host exists
const host = await prisma.host.findUnique({
where: { id: hostId }
});
if (!host) {
return res.status(404).json({ error: 'Host not found' });
}
// If hostGroupId is provided, verify the group exists
if (hostGroupId) {
const hostGroup = await prisma.hostGroup.findUnique({
where: { id: hostGroupId }
});
if (!hostGroup) {
return res.status(400).json({ error: 'Host group not found' });
}
}
// Update host group
const updatedHost = await prisma.host.update({
where: { id: hostId },
data: {
hostGroupId: hostGroupId || null
},
include: {
hostGroup: {
select: {
id: true,
name: true,
color: true
}
}
}
});
res.json({
message: 'Host group updated successfully',
host: updatedHost
});
} catch (error) {
console.error('Host group update error:', error);
res.status(500).json({ error: 'Failed to update host group' });
}
});
// Admin endpoint to list all hosts
router.get('/admin/list', authenticateToken, requireManageHosts, async (req, res) => {
try {
const hosts = await prisma.host.findMany({
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
architecture: true,
lastUpdate: true,
status: true,
apiId: true,
agentVersion: true,
autoUpdate: true,
createdAt: true
},
orderBy: { createdAt: 'desc' }
});
res.json(hosts);
} catch (error) {
console.error('List hosts error:', error);
res.status(500).json({ error: 'Failed to fetch hosts' });
}
});
// Admin endpoint to delete host
router.delete('/:hostId', authenticateToken, requireManageHosts, async (req, res) => {
try {
const { hostId } = req.params;
// Delete host and all related data (cascade)
await prisma.host.delete({
where: { id: hostId }
});
res.json({ message: 'Host deleted successfully' });
} catch (error) {
console.error('Host deletion error:', error);
res.status(500).json({ error: 'Failed to delete host' });
}
});
// Toggle host auto-update setting
router.patch('/:hostId/auto-update', authenticateToken, requireManageHosts, [
body('autoUpdate').isBoolean().withMessage('Auto-update must be a boolean')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { hostId } = req.params;
const { autoUpdate } = req.body;
const host = await prisma.host.update({
where: { id: hostId },
data: { autoUpdate }
});
res.json({
message: `Host auto-update ${autoUpdate ? 'enabled' : 'disabled'} successfully`,
host: {
id: host.id,
hostname: host.hostname,
autoUpdate: host.autoUpdate
}
});
} catch (error) {
console.error('Host auto-update toggle error:', error);
res.status(500).json({ error: 'Failed to toggle host auto-update' });
}
});
// Serve the installation script
router.get('/install', async (req, res) => {
try {
const fs = require('fs');
const path = require('path');
const scriptPath = path.join(__dirname, '../../../agents/patchmon_install.sh');
if (!fs.existsSync(scriptPath)) {
return res.status(404).json({ error: 'Installation script not found' });
}
let script = fs.readFileSync(scriptPath, 'utf8');
// Get the configured server URL from settings
try {
const settings = await prisma.settings.findFirst();
if (settings) {
// Replace the default server URL in the script with the configured one
script = script.replace(
/PATCHMON_URL="[^"]*"/g,
`PATCHMON_URL="${settings.serverUrl}"`
);
}
} catch (settingsError) {
console.warn('Could not fetch settings, using default server URL:', settingsError.message);
}
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Disposition', 'inline; filename="patchmon_install.sh"');
res.send(script);
} catch (error) {
console.error('Installation script error:', error);
res.status(500).json({ error: 'Failed to serve installation script' });
}
});
// ==================== AGENT VERSION MANAGEMENT ====================
// Get all agent versions (admin only)
router.get('/agent/versions', authenticateToken, requireManageSettings, async (req, res) => {
try {
const versions = await prisma.agentVersion.findMany({
orderBy: { createdAt: 'desc' }
});
res.json(versions);
} catch (error) {
console.error('Get agent versions error:', error);
res.status(500).json({ error: 'Failed to get agent versions' });
}
});
// Create new agent version (admin only)
router.post('/agent/versions', authenticateToken, requireManageSettings, [
body('version').isLength({ min: 1 }).withMessage('Version is required'),
body('releaseNotes').optional().isString(),
body('downloadUrl').optional().isURL().withMessage('Download URL must be valid'),
body('minServerVersion').optional().isString(),
body('scriptContent').optional().isString(),
body('isDefault').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { version, releaseNotes, downloadUrl, minServerVersion, scriptContent, isDefault } = req.body;
// Check if version already exists
const existingVersion = await prisma.agentVersion.findUnique({
where: { version }
});
if (existingVersion) {
return res.status(400).json({ error: 'Version already exists' });
}
// If this is being set as default, unset other defaults
if (isDefault) {
await prisma.agentVersion.updateMany({
where: { isDefault: true },
data: { isDefault: false }
});
}
const agentVersion = await prisma.agentVersion.create({
data: {
version,
releaseNotes,
downloadUrl,
minServerVersion,
scriptContent,
isDefault: isDefault || false,
isCurrent: false
}
});
res.status(201).json(agentVersion);
} catch (error) {
console.error('Create agent version error:', error);
res.status(500).json({ error: 'Failed to create agent version' });
}
});
// Set current agent version (admin only)
router.patch('/agent/versions/:versionId/current', authenticateToken, requireManageSettings, async (req, res) => {
try {
const { versionId } = req.params;
// First, unset all current versions
await prisma.agentVersion.updateMany({
where: { isCurrent: true },
data: { isCurrent: false }
});
// Set the specified version as current
const agentVersion = await prisma.agentVersion.update({
where: { id: versionId },
data: { isCurrent: true }
});
res.json(agentVersion);
} catch (error) {
console.error('Set current agent version error:', error);
res.status(500).json({ error: 'Failed to set current agent version' });
}
});
// Set default agent version (admin only)
router.patch('/agent/versions/:versionId/default', authenticateToken, requireManageSettings, async (req, res) => {
try {
const { versionId } = req.params;
// First, unset all default versions
await prisma.agentVersion.updateMany({
where: { isDefault: true },
data: { isDefault: false }
});
// Set the specified version as default
const agentVersion = await prisma.agentVersion.update({
where: { id: versionId },
data: { isDefault: true }
});
res.json(agentVersion);
} catch (error) {
console.error('Set default agent version error:', error);
res.status(500).json({ error: 'Failed to set default agent version' });
}
});
// Delete agent version (admin only)
router.delete('/agent/versions/:versionId', authenticateToken, requireManageSettings, async (req, res) => {
try {
const { versionId } = req.params;
const agentVersion = await prisma.agentVersion.findUnique({
where: { id: versionId }
});
if (!agentVersion) {
return res.status(404).json({ error: 'Agent version not found' });
}
if (agentVersion.isCurrent) {
return res.status(400).json({ error: 'Cannot delete current agent version' });
}
await prisma.agentVersion.delete({
where: { id: versionId }
});
res.json({ message: 'Agent version deleted successfully' });
} catch (error) {
console.error('Delete agent version error:', error);
res.status(500).json({ error: 'Failed to delete agent version' });
}
});
module.exports = router;

View File

@@ -0,0 +1,213 @@
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { body, validationResult } = require('express-validator');
const router = express.Router();
const prisma = new PrismaClient();
// Get all packages with their update status
router.get('/', async (req, res) => {
try {
const {
page = 1,
limit = 50,
search = '',
category = '',
needsUpdate = '',
isSecurityUpdate = ''
} = req.query;
const skip = (parseInt(page) - 1) * parseInt(limit);
const take = parseInt(limit);
// Build where clause
const where = {
AND: [
// Search filter
search ? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } }
]
} : {},
// Category filter
category ? { category: { equals: category } } : {},
// Update status filters
needsUpdate ? {
hostPackages: {
some: {
needsUpdate: needsUpdate === 'true'
}
}
} : {},
isSecurityUpdate ? {
hostPackages: {
some: {
isSecurityUpdate: isSecurityUpdate === 'true'
}
}
} : {}
]
};
// Get packages with counts
const [packages, totalCount] = await Promise.all([
prisma.package.findMany({
where,
select: {
id: true,
name: true,
description: true,
category: true,
latestVersion: true,
createdAt: true,
_count: {
hostPackages: true
}
},
skip,
take,
orderBy: {
name: 'asc'
}
}),
prisma.package.count({ where })
]);
// Get additional stats for each package
const packagesWithStats = await Promise.all(
packages.map(async (pkg) => {
const [updatesCount, securityCount, affectedHosts] = await Promise.all([
prisma.hostPackage.count({
where: {
packageId: pkg.id,
needsUpdate: true
}
}),
prisma.hostPackage.count({
where: {
packageId: pkg.id,
needsUpdate: true,
isSecurityUpdate: true
}
}),
prisma.hostPackage.findMany({
where: {
packageId: pkg.id,
needsUpdate: true
},
select: {
host: {
select: {
id: true,
hostname: true,
osType: true
}
}
},
take: 10 // Limit to first 10 for performance
})
]);
return {
...pkg,
stats: {
totalInstalls: pkg._count.hostPackages,
updatesNeeded: updatesCount,
securityUpdates: securityCount,
affectedHosts: affectedHosts.map(hp => hp.host)
}
};
})
);
res.json({
packages: packagesWithStats,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: totalCount,
pages: Math.ceil(totalCount / parseInt(limit))
}
});
} catch (error) {
console.error('Error fetching packages:', error);
res.status(500).json({ error: 'Failed to fetch packages' });
}
});
// Get package details by ID
router.get('/:packageId', async (req, res) => {
try {
const { packageId } = req.params;
const packageData = await prisma.package.findUnique({
where: { id: packageId },
include: {
hostPackages: {
include: {
host: {
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
lastUpdate: true
}
}
},
orderBy: {
needsUpdate: 'desc'
}
}
}
});
if (!packageData) {
return res.status(404).json({ error: 'Package not found' });
}
// Calculate statistics
const stats = {
totalInstalls: packageData.hostPackages.length,
updatesNeeded: packageData.hostPackages.filter(hp => hp.needsUpdate).length,
securityUpdates: packageData.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length,
upToDate: packageData.hostPackages.filter(hp => !hp.needsUpdate).length
};
// Group by version
const versionDistribution = packageData.hostPackages.reduce((acc, hp) => {
const version = hp.currentVersion;
acc[version] = (acc[version] || 0) + 1;
return acc;
}, {});
// Group by OS type
const osDistribution = packageData.hostPackages.reduce((acc, hp) => {
const osType = hp.host.osType;
acc[osType] = (acc[osType] || 0) + 1;
return acc;
}, {});
res.json({
...packageData,
stats,
distributions: {
versions: Object.entries(versionDistribution).map(([version, count]) => ({
version,
count
})),
osTypes: Object.entries(osDistribution).map(([osType, count]) => ({
osType,
count
}))
}
});
} catch (error) {
console.error('Error fetching package details:', error);
res.status(500).json({ error: 'Failed to fetch package details' });
}
});
module.exports = router;

View File

@@ -0,0 +1,173 @@
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { requireManageSettings } = require('../middleware/permissions');
const router = express.Router();
const prisma = new PrismaClient();
// Get all role permissions
router.get('/roles', authenticateToken, requireManageSettings, async (req, res) => {
try {
const permissions = await prisma.rolePermissions.findMany({
orderBy: {
role: 'asc'
}
});
res.json(permissions);
} catch (error) {
console.error('Get role permissions error:', error);
res.status(500).json({ error: 'Failed to fetch role permissions' });
}
});
// Get permissions for a specific role
router.get('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
try {
const { role } = req.params;
const permissions = await prisma.rolePermissions.findUnique({
where: { role }
});
if (!permissions) {
return res.status(404).json({ error: 'Role not found' });
}
res.json(permissions);
} catch (error) {
console.error('Get role permission error:', error);
res.status(500).json({ error: 'Failed to fetch role permission' });
}
});
// Create or update role permissions
router.put('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
try {
const { role } = req.params;
const {
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canManagePackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings
} = req.body;
// Prevent modifying admin role permissions (admin should always have full access)
if (role === 'admin') {
return res.status(400).json({ error: 'Cannot modify admin role permissions' });
}
const permissions = await prisma.rolePermissions.upsert({
where: { role },
update: {
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canManagePackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings
},
create: {
role,
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canManagePackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings
}
});
res.json({
message: 'Role permissions updated successfully',
permissions
});
} catch (error) {
console.error('Update role permissions error:', error);
res.status(500).json({ error: 'Failed to update role permissions' });
}
});
// Delete a role (and its permissions)
router.delete('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
try {
const { role } = req.params;
// Prevent deleting admin role
if (role === 'admin') {
return res.status(400).json({ error: 'Cannot delete admin role' });
}
// Check if any users are using this role
const usersWithRole = await prisma.user.count({
where: { role }
});
if (usersWithRole > 0) {
return res.status(400).json({
error: `Cannot delete role "${role}" because ${usersWithRole} user(s) are currently using it`
});
}
await prisma.rolePermissions.delete({
where: { role }
});
res.json({
message: `Role "${role}" deleted successfully`
});
} catch (error) {
console.error('Delete role error:', error);
res.status(500).json({ error: 'Failed to delete role' });
}
});
// Get user's permissions based on their role
router.get('/user-permissions', authenticateToken, async (req, res) => {
try {
const userRole = req.user.role;
const permissions = await prisma.rolePermissions.findUnique({
where: { role: userRole }
});
if (!permissions) {
// If no specific permissions found, return default admin permissions
return res.json({
role: userRole,
canViewDashboard: true,
canViewHosts: true,
canManageHosts: true,
canViewPackages: true,
canManagePackages: true,
canViewUsers: true,
canManageUsers: true,
canViewReports: true,
canExportData: true,
canManageSettings: true,
});
}
res.json(permissions);
} catch (error) {
console.error('Get user permissions error:', error);
res.status(500).json({ error: 'Failed to fetch user permissions' });
}
});
module.exports = router;

View File

@@ -0,0 +1,301 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { PrismaClient } = require('@prisma/client');
const { authenticateToken } = require('../middleware/auth');
const { requireViewHosts, requireManageHosts } = require('../middleware/permissions');
const router = express.Router();
const prisma = new PrismaClient();
// Get all repositories with host count
router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
try {
const repositories = await prisma.repository.findMany({
include: {
hostRepositories: {
include: {
host: {
select: {
id: true,
hostname: true,
status: true
}
}
}
},
_count: {
select: {
hostRepositories: true
}
}
},
orderBy: [
{ name: 'asc' },
{ url: 'asc' }
]
});
// Transform data to include host counts and status
const transformedRepos = repositories.map(repo => ({
...repo,
hostCount: repo._count.hostRepositories,
enabledHostCount: repo.hostRepositories.filter(hr => hr.isEnabled).length,
activeHostCount: repo.hostRepositories.filter(hr => hr.host.status === 'active').length,
hosts: repo.hostRepositories.map(hr => ({
id: hr.host.id,
hostname: hr.host.hostname,
status: hr.host.status,
isEnabled: hr.isEnabled,
lastChecked: hr.lastChecked
}))
}));
res.json(transformedRepos);
} catch (error) {
console.error('Repository list error:', error);
res.status(500).json({ error: 'Failed to fetch repositories' });
}
});
// Get repositories for a specific host
router.get('/host/:hostId', authenticateToken, requireViewHosts, async (req, res) => {
try {
const { hostId } = req.params;
const hostRepositories = await prisma.hostRepository.findMany({
where: { hostId },
include: {
repository: true,
host: {
select: {
id: true,
hostname: true
}
}
},
orderBy: {
repository: {
name: 'asc'
}
}
});
res.json(hostRepositories);
} catch (error) {
console.error('Host repositories error:', error);
res.status(500).json({ error: 'Failed to fetch host repositories' });
}
});
// Get repository details with all hosts
router.get('/:repositoryId', authenticateToken, requireViewHosts, async (req, res) => {
try {
const { repositoryId } = req.params;
const repository = await prisma.repository.findUnique({
where: { id: repositoryId },
include: {
hostRepositories: {
include: {
host: {
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
status: true,
lastUpdate: true
}
}
},
orderBy: {
host: {
hostname: 'asc'
}
}
}
}
});
if (!repository) {
return res.status(404).json({ error: 'Repository not found' });
}
res.json(repository);
} catch (error) {
console.error('Repository detail error:', error);
res.status(500).json({ error: 'Failed to fetch repository details' });
}
});
// Update repository information (admin only)
router.put('/:repositoryId', authenticateToken, requireManageHosts, [
body('name').optional().isLength({ min: 1 }).withMessage('Name is required'),
body('description').optional(),
body('isActive').optional().isBoolean().withMessage('isActive must be a boolean'),
body('priority').optional().isInt({ min: 0 }).withMessage('Priority must be a positive integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { repositoryId } = req.params;
const { name, description, isActive, priority } = req.body;
const repository = await prisma.repository.update({
where: { id: repositoryId },
data: {
...(name && { name }),
...(description !== undefined && { description }),
...(isActive !== undefined && { isActive }),
...(priority !== undefined && { priority })
},
include: {
_count: {
select: {
hostRepositories: true
}
}
}
});
res.json(repository);
} catch (error) {
console.error('Repository update error:', error);
res.status(500).json({ error: 'Failed to update repository' });
}
});
// Toggle repository status for a specific host
router.patch('/host/:hostId/repository/:repositoryId', authenticateToken, requireManageHosts, [
body('isEnabled').isBoolean().withMessage('isEnabled must be a boolean')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { hostId, repositoryId } = req.params;
const { isEnabled } = req.body;
const hostRepository = await prisma.hostRepository.update({
where: {
hostId_repositoryId: {
hostId,
repositoryId
}
},
data: {
isEnabled,
lastChecked: new Date()
},
include: {
repository: true,
host: {
select: {
hostname: true
}
}
}
});
res.json({
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.host.hostname}`,
hostRepository
});
} catch (error) {
console.error('Host repository toggle error:', error);
res.status(500).json({ error: 'Failed to toggle repository status' });
}
});
// Get repository statistics
router.get('/stats/summary', authenticateToken, requireViewHosts, async (req, res) => {
try {
const stats = await prisma.repository.aggregate({
_count: true
});
const hostRepoStats = await prisma.hostRepository.aggregate({
_count: {
isEnabled: true
},
where: {
isEnabled: true
}
});
const secureRepos = await prisma.repository.count({
where: { isSecure: true }
});
const activeRepos = await prisma.repository.count({
where: { isActive: true }
});
res.json({
totalRepositories: stats._count,
activeRepositories: activeRepos,
secureRepositories: secureRepos,
enabledHostRepositories: hostRepoStats._count.isEnabled,
securityPercentage: stats._count > 0 ? Math.round((secureRepos / stats._count) * 100) : 0
});
} catch (error) {
console.error('Repository stats error:', error);
res.status(500).json({ error: 'Failed to fetch repository statistics' });
}
});
// Cleanup orphaned repositories (admin only)
router.delete('/cleanup/orphaned', authenticateToken, requireManageHosts, async (req, res) => {
try {
console.log('Cleaning up orphaned repositories...');
// Find repositories with no host relationships
const orphanedRepos = await prisma.repository.findMany({
where: {
hostRepositories: {
none: {}
}
}
});
if (orphanedRepos.length === 0) {
return res.json({
message: 'No orphaned repositories found',
deletedCount: 0,
deletedRepositories: []
});
}
// Delete orphaned repositories
const deleteResult = await prisma.repository.deleteMany({
where: {
hostRepositories: {
none: {}
}
}
});
console.log(`Deleted ${deleteResult.count} orphaned repositories`);
res.json({
message: `Successfully deleted ${deleteResult.count} orphaned repositories`,
deletedCount: deleteResult.count,
deletedRepositories: orphanedRepos.map(repo => ({
id: repo.id,
name: repo.name,
url: repo.url
}))
});
} catch (error) {
console.error('Repository cleanup error:', error);
res.status(500).json({ error: 'Failed to cleanup orphaned repositories' });
}
});
module.exports = router;

View File

@@ -0,0 +1,253 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { PrismaClient } = require('@prisma/client');
const { authenticateToken } = require('../middleware/auth');
const { requireManageSettings } = require('../middleware/permissions');
const router = express.Router();
const prisma = new PrismaClient();
// Function to trigger crontab updates on all hosts with auto-update enabled
async function triggerCrontabUpdates() {
try {
console.log('Triggering crontab updates on all hosts with auto-update enabled...');
// Get all hosts that have auto-update enabled
const hosts = await prisma.host.findMany({
where: {
autoUpdate: true,
status: 'active' // Only update active hosts
},
select: {
id: true,
hostname: true,
apiId: true,
apiKey: true
}
});
console.log(`Found ${hosts.length} hosts with auto-update enabled`);
// For each host, we'll send a special update command that triggers crontab update
// This is done by sending a ping with a special flag
for (const host of hosts) {
try {
console.log(`Triggering crontab update for host: ${host.hostname}`);
// We'll use the existing ping endpoint but add a special parameter
// The agent will detect this and run update-crontab command
const http = require('http');
const https = require('https');
const serverUrl = process.env.SERVER_URL || 'http://localhost:3001';
const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
const isHttps = url.protocol === 'https:';
const client = isHttps ? https : http;
const postData = JSON.stringify({
triggerCrontabUpdate: true,
message: 'Update interval changed, please update your crontab'
});
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
'X-API-ID': host.apiId,
'X-API-KEY': host.apiKey
}
};
const req = client.request(options, (res) => {
if (res.statusCode === 200) {
console.log(`Successfully triggered crontab update for ${host.hostname}`);
} else {
console.error(`Failed to trigger crontab update for ${host.hostname}: ${res.statusCode}`);
}
});
req.on('error', (error) => {
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
});
req.write(postData);
req.end();
} catch (error) {
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
}
}
console.log('Crontab update trigger completed');
} catch (error) {
console.error('Error in triggerCrontabUpdates:', error);
}
}
// Get current settings
router.get('/', authenticateToken, requireManageSettings, async (req, res) => {
try {
let settings = await prisma.settings.findFirst();
// If no settings exist, create default settings
if (!settings) {
settings = await prisma.settings.create({
data: {
serverUrl: 'http://localhost:3001',
serverProtocol: 'http',
serverHost: 'localhost',
serverPort: 3001,
frontendUrl: 'http://localhost:3000',
updateInterval: 60,
autoUpdate: false
}
});
}
console.log('Returning settings:', settings);
res.json(settings);
} catch (error) {
console.error('Settings fetch error:', error);
res.status(500).json({ error: 'Failed to fetch settings' });
}
});
// Update settings
router.put('/', authenticateToken, requireManageSettings, [
body('serverProtocol').isIn(['http', 'https']).withMessage('Protocol must be http or https'),
body('serverHost').isLength({ min: 1 }).withMessage('Server host is required'),
body('serverPort').isInt({ min: 1, max: 65535 }).withMessage('Port must be between 1 and 65535'),
body('frontendUrl').isLength({ min: 1 }).withMessage('Frontend URL is required'),
body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'),
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean')
], async (req, res) => {
try {
console.log('Settings update request body:', req.body);
const errors = validationResult(req);
if (!errors.isEmpty()) {
console.log('Validation errors:', errors.array());
return res.status(400).json({ errors: errors.array() });
}
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate } = req.body;
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate });
// Construct server URL from components
const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`;
let settings = await prisma.settings.findFirst();
if (settings) {
// Update existing settings
console.log('Updating existing settings with data:', {
serverUrl,
serverProtocol,
serverHost,
serverPort,
frontendUrl,
updateInterval: updateInterval || 60,
autoUpdate: autoUpdate || false
});
const oldUpdateInterval = settings.updateInterval;
settings = await prisma.settings.update({
where: { id: settings.id },
data: {
serverUrl,
serverProtocol,
serverHost,
serverPort,
frontendUrl,
updateInterval: updateInterval || 60,
autoUpdate: autoUpdate || false
}
});
console.log('Settings updated successfully:', settings);
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
if (oldUpdateInterval !== (updateInterval || 60)) {
console.log(`Update interval changed from ${oldUpdateInterval} to ${updateInterval || 60} minutes. Triggering crontab updates...`);
await triggerCrontabUpdates();
}
} else {
// Create new settings
settings = await prisma.settings.create({
data: {
serverUrl,
serverProtocol,
serverHost,
serverPort,
frontendUrl,
updateInterval: updateInterval || 60,
autoUpdate: autoUpdate || false
}
});
}
res.json({
message: 'Settings updated successfully',
settings
});
} catch (error) {
console.error('Settings update error:', error);
res.status(500).json({ error: 'Failed to update settings' });
}
});
// Get server URL for public use (used by installation scripts)
router.get('/server-url', async (req, res) => {
try {
const settings = await prisma.settings.findFirst();
if (!settings) {
return res.json({ serverUrl: 'http://localhost:3001' });
}
res.json({ serverUrl: settings.serverUrl });
} catch (error) {
console.error('Server URL fetch error:', error);
res.json({ serverUrl: 'http://localhost:3001' });
}
});
// Get update interval policy for agents (public endpoint)
router.get('/update-interval', async (req, res) => {
try {
const settings = await prisma.settings.findFirst();
if (!settings) {
return res.json({ updateInterval: 60 });
}
res.json({
updateInterval: settings.updateInterval,
cronExpression: `*/${settings.updateInterval} * * * *` // Generate cron expression
});
} catch (error) {
console.error('Update interval fetch error:', error);
res.json({ updateInterval: 60, cronExpression: '0 * * * *' });
}
});
// Get auto-update policy for agents (public endpoint)
router.get('/auto-update', async (req, res) => {
try {
const settings = await prisma.settings.findFirst();
if (!settings) {
return res.json({ autoUpdate: false });
}
res.json({
autoUpdate: settings.autoUpdate || false
});
} catch (error) {
console.error('Auto-update fetch error:', error);
res.json({ autoUpdate: false });
}
});
module.exports = router;

179
backend/src/server.js Normal file
View File

@@ -0,0 +1,179 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { PrismaClient } = require('@prisma/client');
const winston = require('winston');
// Import routes
const authRoutes = require('./routes/authRoutes');
const hostRoutes = require('./routes/hostRoutes');
const hostGroupRoutes = require('./routes/hostGroupRoutes');
const packageRoutes = require('./routes/packageRoutes');
const dashboardRoutes = require('./routes/dashboardRoutes');
const permissionsRoutes = require('./routes/permissionsRoutes');
const settingsRoutes = require('./routes/settingsRoutes');
const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes');
const repositoryRoutes = require('./routes/repositoryRoutes');
// Initialize Prisma client
const prisma = new PrismaClient();
// Initialize logger - only if logging is enabled
const logger = process.env.ENABLE_LOGGING === 'true' ? winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' }),
],
}) : {
info: () => {},
error: () => {},
warn: () => {},
debug: () => {}
};
if (process.env.ENABLE_LOGGING === 'true' && process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple()
}));
}
const app = express();
const PORT = process.env.PORT || 3001;
// Trust proxy (needed when behind reverse proxy) and remove X-Powered-By
if (process.env.TRUST_PROXY) {
app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : parseInt(process.env.TRUST_PROXY, 10) || true);
} else {
app.set('trust proxy', 1);
}
app.disable('x-powered-by');
// Rate limiting
const limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_MAX) || 100,
message: 'Too many requests from this IP, please try again later.',
});
// Middleware
// Helmet with stricter defaults (CSP/HSTS only in production)
app.use(helmet({
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
useDefaults: true,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"]
}
} : false,
hsts: process.env.ENABLE_HSTS === 'true' || process.env.NODE_ENV === 'production'
}));
// CORS allowlist from comma-separated env
const parseOrigins = (val) => (val || '').split(',').map(s => s.trim()).filter(Boolean);
const allowedOrigins = parseOrigins(process.env.CORS_ORIGINS || process.env.CORS_ORIGIN || 'http://localhost:3000');
app.use(cors({
origin: function(origin, callback) {
// Allow non-browser/SSR tools with no origin
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true);
return callback(new Error('Not allowed by CORS'));
},
credentials: true
}));
app.use(limiter);
// Reduce body size limits to reasonable defaults
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '5mb' }));
app.use(express.urlencoded({ extended: true, limit: process.env.JSON_BODY_LIMIT || '5mb' }));
// Request logging - only if logging is enabled
if (process.env.ENABLE_LOGGING === 'true') {
app.use((req, res, next) => {
logger.info(`${req.method} ${req.path} - ${req.ip}`);
next();
});
}
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API routes
const apiVersion = process.env.API_VERSION || 'v1';
// Per-route rate limits
const authLimiter = rateLimit({
windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS) || 10 * 60 * 1000,
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX) || 20
});
const agentLimiter = rateLimit({
windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS) || 60 * 1000,
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX) || 120
});
app.use(`/api/${apiVersion}/auth`, authLimiter, authRoutes);
app.use(`/api/${apiVersion}/hosts`, agentLimiter, hostRoutes);
app.use(`/api/${apiVersion}/host-groups`, hostGroupRoutes);
app.use(`/api/${apiVersion}/packages`, packageRoutes);
app.use(`/api/${apiVersion}/dashboard`, dashboardRoutes);
app.use(`/api/${apiVersion}/permissions`, permissionsRoutes);
app.use(`/api/${apiVersion}/settings`, settingsRoutes);
app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.error(err.stack);
}
res.status(500).json({
error: 'Something went wrong!',
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
});
// 404 handler
app.use('*', (req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Graceful shutdown
process.on('SIGTERM', async () => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.info('SIGTERM received, shutting down gracefully');
}
await prisma.$disconnect();
process.exit(0);
});
process.on('SIGINT', async () => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.info('SIGINT received, shutting down gracefully');
}
await prisma.$disconnect();
process.exit(0);
});
// Start server
app.listen(PORT, () => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`);
}
});
module.exports = app;