diff --git a/.gitignore b/.gitignore index 4089315..594bbbf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Logs logs +!apps/api/src/domains/logs +!apps/web/src/components/pages/Logs.tsx *.log npm-debug.log* yarn-debug.log* diff --git a/apps/api/src/domains/access-lists/dto/access-lists.dto.ts b/apps/api/src/domains/access-lists/dto/access-lists.dto.ts index b4ad665..7d9574c 100644 --- a/apps/api/src/domains/access-lists/dto/access-lists.dto.ts +++ b/apps/api/src/domains/access-lists/dto/access-lists.dto.ts @@ -1,4 +1,5 @@ import { body, param, query } from 'express-validator'; +import { isValidIpOrCidr } from '../../acl/utils/validators'; /** * Validation rules for creating an access list @@ -38,8 +39,13 @@ export const createAccessListValidation = [ body('allowedIps.*') .optional() .trim() - .matches(/^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/) - .withMessage('Each IP must be a valid IPv4 address or CIDR notation'), + .custom((value) => { + if (!value) return true; + if (!isValidIpOrCidr(value)) { + throw new Error('Invalid IP address or CIDR notation. Examples: 192.168.1.1 or 192.168.1.0/24'); + } + return true; + }), body('authUsers') .optional() @@ -125,8 +131,13 @@ export const updateAccessListValidation = [ body('allowedIps.*') .optional() .trim() - .matches(/^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/) - .withMessage('Each IP must be a valid IPv4 address or CIDR notation'), + .custom((value) => { + if (!value) return true; + if (!isValidIpOrCidr(value)) { + throw new Error('Invalid IP address or CIDR notation. Examples: 192.168.1.1 or 192.168.1.0/24'); + } + return true; + }), body('authUsers') .optional() diff --git a/apps/api/src/domains/acl/acl.controller.ts b/apps/api/src/domains/acl/acl.controller.ts index a2e214a..26d3915 100644 --- a/apps/api/src/domains/acl/acl.controller.ts +++ b/apps/api/src/domains/acl/acl.controller.ts @@ -181,6 +181,31 @@ export class AclController { } } + /** + * Preview ACL configuration without applying + * @route GET /api/acl/preview + */ + async previewAclConfig(req: Request, res: Response): Promise { + try { + const config = await aclService.previewNginxConfig(); + + res.json({ + success: true, + data: { + config, + rulesCount: await aclService.getEnabledRulesCount() + } + }); + } catch (error: any) { + logger.error('Failed to preview ACL config:', error); + res.status(500).json({ + success: false, + message: 'Failed to preview ACL configuration', + error: error.message + }); + } + } + /** * Apply ACL rules to Nginx * @route POST /api/acl/apply diff --git a/apps/api/src/domains/acl/acl.routes.ts b/apps/api/src/domains/acl/acl.routes.ts index 8eb7190..241cca8 100644 --- a/apps/api/src/domains/acl/acl.routes.ts +++ b/apps/api/src/domains/acl/acl.routes.ts @@ -28,6 +28,13 @@ router.get('/:id', (req, res) => aclController.getAclRule(req, res)); */ router.post('/', authorize('admin', 'moderator'), (req, res) => aclController.createAclRule(req, res)); +/** + * @route GET /api/acl/preview + * @desc Preview ACL configuration without applying + * @access Private (all roles) + */ +router.get('/preview', (req, res) => aclController.previewAclConfig(req, res)); + /** * @route POST /api/acl/apply * @desc Apply ACL rules to Nginx diff --git a/apps/api/src/domains/acl/acl.service.ts b/apps/api/src/domains/acl/acl.service.ts index 1c20121..190d4da 100644 --- a/apps/api/src/domains/acl/acl.service.ts +++ b/apps/api/src/domains/acl/acl.service.ts @@ -105,6 +105,21 @@ export class AclService { return rule; } + /** + * Preview Nginx configuration without applying + */ + async previewNginxConfig(): Promise { + return aclNginxService.generateAclConfig(); + } + + /** + * Get count of enabled rules + */ + async getEnabledRulesCount(): Promise { + const rules = await aclRepository.findEnabled(); + return rules.length; + } + /** * Apply ACL rules to Nginx */ diff --git a/apps/api/src/domains/acl/dto/create-acl-rule.dto.ts b/apps/api/src/domains/acl/dto/create-acl-rule.dto.ts index 5a50918..81396ab 100644 --- a/apps/api/src/domains/acl/dto/create-acl-rule.dto.ts +++ b/apps/api/src/domains/acl/dto/create-acl-rule.dto.ts @@ -1,3 +1,5 @@ +import { validateAclValue, sanitizeValue } from '../utils/validators'; + /** * DTO for creating ACL rule */ @@ -17,34 +19,76 @@ export interface CreateAclRuleDto { export function validateCreateAclRuleDto(data: any): { isValid: boolean; errors: string[] } { const errors: string[] = []; + // Validate name if (!data.name || typeof data.name !== 'string' || !data.name.trim()) { errors.push('Name is required and must be a non-empty string'); + } else if (data.name.length > 100) { + errors.push('Name must not exceed 100 characters'); } + // Validate type + const validTypes = ['whitelist', 'blacklist']; if (!data.type || typeof data.type !== 'string') { errors.push('Type is required and must be a string'); + } else if (!validTypes.includes(data.type)) { + errors.push(`Type must be one of: ${validTypes.join(', ')}`); } + // Validate condition field + const validFields = ['ip', 'geoip', 'user_agent', 'url', 'method', 'header']; if (!data.conditionField || typeof data.conditionField !== 'string') { errors.push('Condition field is required and must be a string'); + } else if (!validFields.includes(data.conditionField)) { + errors.push(`Condition field must be one of: ${validFields.join(', ')}`); } + // Validate condition operator + const validOperators = ['equals', 'contains', 'regex']; if (!data.conditionOperator || typeof data.conditionOperator !== 'string') { errors.push('Condition operator is required and must be a string'); + } else if (!validOperators.includes(data.conditionOperator)) { + errors.push(`Condition operator must be one of: ${validOperators.join(', ')}`); } + // Validate condition value if (!data.conditionValue || typeof data.conditionValue !== 'string') { errors.push('Condition value is required and must be a string'); + } else if (data.conditionValue.trim().length === 0) { + errors.push('Condition value cannot be empty'); + } else { + // Perform field-specific validation + const valueValidation = validateAclValue( + data.conditionField, + data.conditionOperator, + data.conditionValue + ); + + if (!valueValidation.valid) { + errors.push(valueValidation.error || 'Invalid condition value'); + } } + // Validate action + const validActions = ['allow', 'deny', 'challenge']; if (!data.action || typeof data.action !== 'string') { errors.push('Action is required and must be a string'); + } else if (!validActions.includes(data.action)) { + errors.push(`Action must be one of: ${validActions.join(', ')}`); } + // Validate enabled if (data.enabled !== undefined && typeof data.enabled !== 'boolean') { errors.push('Enabled must be a boolean'); } + // Validate type-action combinations + if (data.type === 'whitelist' && data.action === 'deny') { + errors.push('Whitelist rules should use "allow" action, not "deny"'); + } + if (data.type === 'blacklist' && data.action === 'allow') { + errors.push('Blacklist rules should use "deny" action, not "allow"'); + } + return { isValid: errors.length === 0, errors diff --git a/apps/api/src/domains/acl/dto/update-acl-rule.dto.ts b/apps/api/src/domains/acl/dto/update-acl-rule.dto.ts index 15e82f5..6b17c21 100644 --- a/apps/api/src/domains/acl/dto/update-acl-rule.dto.ts +++ b/apps/api/src/domains/acl/dto/update-acl-rule.dto.ts @@ -1,3 +1,5 @@ +import { validateAclValue } from '../utils/validators'; + /** * DTO for updating ACL rule */ @@ -17,34 +19,78 @@ export interface UpdateAclRuleDto { export function validateUpdateAclRuleDto(data: any): { isValid: boolean; errors: string[] } { const errors: string[] = []; + // Validate name if (data.name !== undefined && (typeof data.name !== 'string' || !data.name.trim())) { errors.push('Name must be a non-empty string'); + } else if (data.name && data.name.length > 100) { + errors.push('Name must not exceed 100 characters'); } + // Validate type + const validTypes = ['whitelist', 'blacklist']; if (data.type !== undefined && typeof data.type !== 'string') { errors.push('Type must be a string'); + } else if (data.type && !validTypes.includes(data.type)) { + errors.push(`Type must be one of: ${validTypes.join(', ')}`); } + // Validate condition field + const validFields = ['ip', 'geoip', 'user_agent', 'url', 'method', 'header']; if (data.conditionField !== undefined && typeof data.conditionField !== 'string') { errors.push('Condition field must be a string'); + } else if (data.conditionField && !validFields.includes(data.conditionField)) { + errors.push(`Condition field must be one of: ${validFields.join(', ')}`); } + // Validate condition operator + const validOperators = ['equals', 'contains', 'regex']; if (data.conditionOperator !== undefined && typeof data.conditionOperator !== 'string') { errors.push('Condition operator must be a string'); + } else if (data.conditionOperator && !validOperators.includes(data.conditionOperator)) { + errors.push(`Condition operator must be one of: ${validOperators.join(', ')}`); } - if (data.conditionValue !== undefined && typeof data.conditionValue !== 'string') { - errors.push('Condition value must be a string'); + // Validate condition value with field-specific validation + if (data.conditionValue !== undefined) { + if (typeof data.conditionValue !== 'string') { + errors.push('Condition value must be a string'); + } else if (data.conditionValue.trim().length === 0) { + errors.push('Condition value cannot be empty'); + } else if (data.conditionField && data.conditionOperator) { + // Perform field-specific validation if we have all required fields + const valueValidation = validateAclValue( + data.conditionField, + data.conditionOperator, + data.conditionValue + ); + + if (!valueValidation.valid) { + errors.push(valueValidation.error || 'Invalid condition value'); + } + } } + // Validate action + const validActions = ['allow', 'deny', 'challenge']; if (data.action !== undefined && typeof data.action !== 'string') { errors.push('Action must be a string'); + } else if (data.action && !validActions.includes(data.action)) { + errors.push(`Action must be one of: ${validActions.join(', ')}`); } + // Validate enabled if (data.enabled !== undefined && typeof data.enabled !== 'boolean') { errors.push('Enabled must be a boolean'); } + // Validate type-action combinations + if (data.type === 'whitelist' && data.action === 'deny') { + errors.push('Whitelist rules should use "allow" action, not "deny"'); + } + if (data.type === 'blacklist' && data.action === 'allow') { + errors.push('Blacklist rules should use "deny" action, not "allow"'); + } + return { isValid: errors.length === 0, errors diff --git a/apps/api/src/domains/acl/utils/validators.ts b/apps/api/src/domains/acl/utils/validators.ts new file mode 100644 index 0000000..a5f0b63 --- /dev/null +++ b/apps/api/src/domains/acl/utils/validators.ts @@ -0,0 +1,278 @@ +/** + * ACL Validation Utilities + * Provides comprehensive validation for ACL rule values to prevent nginx configuration errors + */ + +/** + * Validate IP address (IPv4 or IPv6) + */ +export function isValidIpAddress(ip: string): boolean { + // IPv4 validation + const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + + // IPv6 validation (simplified) + const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^[0-9a-fA-F]{1,4}::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}$/; + + return ipv4Regex.test(ip) || ipv6Regex.test(ip); +} + +/** + * Validate CIDR notation (e.g., 192.168.1.0/24) + */ +export function isValidCidr(cidr: string): boolean { + const cidrRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/(?:[0-9]|[1-2][0-9]|3[0-2])$/; + + // IPv6 CIDR + const cidrV6Regex = /^(?:[0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{0,4}\/(?:[0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$/; + + return cidrRegex.test(cidr) || cidrV6Regex.test(cidr); +} + +/** + * Validate IP or CIDR + */ +export function isValidIpOrCidr(value: string): boolean { + return isValidIpAddress(value) || isValidCidr(value); +} + +/** + * Validate regex pattern (check if it's a valid regex) + */ +export function isValidRegex(pattern: string): { valid: boolean; error?: string } { + try { + new RegExp(pattern); + return { valid: true }; + } catch (error: any) { + return { valid: false, error: error.message }; + } +} + +/** + * Validate URL pattern for nginx location matching + */ +export function isValidUrlPattern(pattern: string): boolean { + // URL pattern should not be empty and should be a valid path + if (!pattern || pattern.trim().length === 0) { + return false; + } + + // Check for dangerous characters that could break nginx config + const dangerousChars = /[;<>{}|\\]/; + if (dangerousChars.test(pattern)) { + return false; + } + + return true; +} + +/** + * Validate HTTP method + */ +export function isValidHttpMethod(method: string): boolean { + const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'CONNECT', 'TRACE']; + return validMethods.includes(method.toUpperCase()); +} + +/** + * Validate GeoIP country code (ISO 3166-1 alpha-2) + */ +export function isValidCountryCode(code: string): boolean { + // Basic validation: 2 uppercase letters + return /^[A-Z]{2}$/.test(code); +} + +/** + * Validate User-Agent pattern + */ +export function isValidUserAgentPattern(pattern: string): boolean { + if (!pattern || pattern.trim().length === 0) { + return false; + } + + // Check for dangerous characters + const dangerousChars = /[;<>{}|\\]/; + if (dangerousChars.test(pattern)) { + return false; + } + + return true; +} + +/** + * Validate header name + */ +export function isValidHeaderName(name: string): boolean { + // HTTP header names should only contain alphanumeric, dash, and underscore + return /^[a-zA-Z0-9\-_]+$/.test(name); +} + +/** + * Sanitize value to prevent nginx config injection + */ +export function sanitizeValue(value: string): string { + // Remove or escape dangerous characters + return value + .replace(/[;<>{}|\\]/g, '') // Remove dangerous chars + .replace(/\$/g, '\\$') // Escape dollar signs + .trim(); +} + +/** + * Validate ACL rule value based on field and operator + */ +export function validateAclValue( + field: string, + operator: string, + value: string +): { valid: boolean; error?: string } { + if (!value || value.trim().length === 0) { + return { valid: false, error: 'Value cannot be empty' }; + } + + switch (field) { + case 'ip': + if (operator === 'equals' || operator === 'contains') { + if (!isValidIpOrCidr(value)) { + return { + valid: false, + error: 'Invalid IP address or CIDR notation. Examples: 192.168.1.1 or 192.168.1.0/24' + }; + } + } else if (operator === 'regex') { + const regexCheck = isValidRegex(value); + if (!regexCheck.valid) { + return { + valid: false, + error: `Invalid regex pattern: ${regexCheck.error}` + }; + } + } + break; + + case 'geoip': + if (operator === 'equals') { + if (!isValidCountryCode(value)) { + return { + valid: false, + error: 'Invalid country code. Use ISO 3166-1 alpha-2 format (e.g., US, CN, VN)' + }; + } + } else if (operator === 'regex') { + const regexCheck = isValidRegex(value); + if (!regexCheck.valid) { + return { + valid: false, + error: `Invalid regex pattern: ${regexCheck.error}` + }; + } + } + break; + + case 'user_agent': + if (operator === 'regex') { + const regexCheck = isValidRegex(value); + if (!regexCheck.valid) { + return { + valid: false, + error: `Invalid regex pattern: ${regexCheck.error}` + }; + } + } else if (!isValidUserAgentPattern(value)) { + return { + valid: false, + error: 'Invalid user-agent pattern. Avoid special characters like ; < > { } | \\' + }; + } + break; + + case 'url': + if (operator === 'regex') { + const regexCheck = isValidRegex(value); + if (!regexCheck.valid) { + return { + valid: false, + error: `Invalid regex pattern: ${regexCheck.error}` + }; + } + } else if (!isValidUrlPattern(value)) { + return { + valid: false, + error: 'Invalid URL pattern. Avoid special characters like ; < > { } | \\' + }; + } + break; + + case 'method': + if (operator === 'equals' && !isValidHttpMethod(value)) { + return { + valid: false, + error: 'Invalid HTTP method. Valid methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS' + }; + } + break; + + case 'header': + // For header field, value should be in format "Header-Name: value" + const headerParts = value.split(':'); + if (headerParts.length < 2) { + return { + valid: false, + error: 'Header value must be in format "Header-Name: value"' + }; + } + + const headerName = headerParts[0].trim(); + if (!isValidHeaderName(headerName)) { + return { + valid: false, + error: 'Invalid header name. Use only alphanumeric, dash, and underscore characters' + }; + } + break; + + default: + return { valid: false, error: `Unknown field type: ${field}` }; + } + + return { valid: true }; +} + +/** + * Get validation hints for a specific field type + */ +export function getValidationHints(field: string, operator: string): string { + const hints: Record> = { + ip: { + equals: 'Enter a valid IP address (e.g., 192.168.1.1)', + contains: 'Enter a valid CIDR notation (e.g., 192.168.1.0/24)', + regex: 'Enter a valid regex pattern for IP matching' + }, + geoip: { + equals: 'Enter a 2-letter country code (e.g., US, CN, VN)', + contains: 'Enter country codes separated by comma', + regex: 'Enter a regex pattern for country codes' + }, + user_agent: { + equals: 'Enter exact user-agent string', + contains: 'Enter a substring to match in user-agent', + regex: 'Enter a regex pattern (e.g., (bot|crawler|spider))' + }, + url: { + equals: 'Enter exact URL path (e.g., /admin)', + contains: 'Enter a substring to match in URL', + regex: 'Enter a regex pattern (e.g., \\.(php|asp)$)' + }, + method: { + equals: 'Enter HTTP method (GET, POST, PUT, DELETE, etc.)', + contains: 'Enter HTTP method substring', + regex: 'Enter regex pattern for HTTP methods' + }, + header: { + equals: 'Enter in format "Header-Name: value"', + contains: 'Enter in format "Header-Name: value"', + regex: 'Enter in format "Header-Name: regex-pattern"' + } + }; + + return hints[field]?.[operator] || 'Enter a valid value'; +} diff --git a/apps/api/src/domains/dashboard/dashboard.controller.ts b/apps/api/src/domains/dashboard/dashboard.controller.ts index 6b90851..7dd6335 100644 --- a/apps/api/src/domains/dashboard/dashboard.controller.ts +++ b/apps/api/src/domains/dashboard/dashboard.controller.ts @@ -7,6 +7,7 @@ import { AuthRequest } from '../../middleware/auth'; import logger from '../../utils/logger'; import { DashboardService } from './dashboard.service'; import { GetMetricsQueryDto, GetRecentAlertsQueryDto } from './dto'; +import { dashboardAnalyticsService } from './services/dashboard-analytics.service'; const dashboardService = new DashboardService(); @@ -82,3 +83,196 @@ export const getRecentAlerts = async ( }); } }; + +/** + * Get request trend analytics (auto-refresh every 5s) + */ +export const getRequestTrend = async ( + req: AuthRequest, + res: Response +): Promise => { + try { + const { interval = 5 } = req.query; + const intervalSeconds = Math.max(5, Math.min(60, Number(interval))); + + const trend = await dashboardAnalyticsService.getRequestTrend(intervalSeconds); + + res.json({ + success: true, + data: trend, + }); + } catch (error) { + logger.error('Get request trend error:', error); + res.status(500).json({ + success: false, + message: 'Failed to get request trend', + }); + } +}; + +/** + * Get slow requests from performance monitoring + */ +export const getSlowRequests = async ( + req: AuthRequest, + res: Response +): Promise => { + try { + const { limit = 10 } = req.query; + const slowRequests = await dashboardAnalyticsService.getSlowRequests(Number(limit)); + + res.json({ + success: true, + data: slowRequests, + }); + } catch (error) { + logger.error('Get slow requests error:', error); + res.status(500).json({ + success: false, + message: 'Failed to get slow requests', + }); + } +}; + +/** + * Get latest attack statistics (top 5 in 24h) + */ +export const getLatestAttackStats = async ( + req: AuthRequest, + res: Response +): Promise => { + try { + const { limit = 5 } = req.query; + const attacks = await dashboardAnalyticsService.getLatestAttacks(Number(limit)); + + res.json({ + success: true, + data: attacks, + }); + } catch (error) { + logger.error('Get latest attack stats error:', error); + res.status(500).json({ + success: false, + message: 'Failed to get latest attack statistics', + }); + } +}; + +/** + * Get latest security news/events + */ +export const getLatestNews = async ( + req: AuthRequest, + res: Response +): Promise => { + try { + const { limit = 20 } = req.query; + const news = await dashboardAnalyticsService.getLatestNews(Number(limit)); + + res.json({ + success: true, + data: news, + }); + } catch (error) { + logger.error('Get latest news error:', error); + res.status(500).json({ + success: false, + message: 'Failed to get latest news', + }); + } +}; + +/** + * Get request analytics (top IPs by period) + */ +export const getRequestAnalytics = async ( + req: AuthRequest, + res: Response +): Promise => { + try { + const { period = 'day' } = req.query; + const validPeriod = ['day', 'week', 'month'].includes(period as string) + ? (period as 'day' | 'week' | 'month') + : 'day'; + + const analytics = await dashboardAnalyticsService.getRequestAnalytics(validPeriod); + + res.json({ + success: true, + data: analytics, + }); + } catch (error) { + logger.error('Get request analytics error:', error); + res.status(500).json({ + success: false, + message: 'Failed to get request analytics', + }); + } +}; + +/** + * Get attack vs normal request ratio + */ +export const getAttackRatio = async ( + req: AuthRequest, + res: Response +): Promise => { + try { + const ratio = await dashboardAnalyticsService.getAttackRatio(); + + res.json({ + success: true, + data: ratio, + }); + } catch (error) { + logger.error('Get attack ratio error:', error); + res.status(500).json({ + success: false, + message: 'Failed to get attack ratio', + }); + } +}; + +/** + * Get complete dashboard analytics + */ +export const getDashboardAnalytics = async ( + req: AuthRequest, + res: Response +): Promise => { + try { + const [ + requestTrend, + slowRequests, + latestAttacks, + latestNews, + requestAnalytics, + attackRatio, + ] = await Promise.all([ + dashboardAnalyticsService.getRequestTrend(5), + dashboardAnalyticsService.getSlowRequests(10), + dashboardAnalyticsService.getLatestAttacks(5), + dashboardAnalyticsService.getLatestNews(20), + dashboardAnalyticsService.getRequestAnalytics('day'), + dashboardAnalyticsService.getAttackRatio(), + ]); + + res.json({ + success: true, + data: { + requestTrend, + slowRequests, + latestAttacks, + latestNews, + requestAnalytics, + attackRatio, + }, + }); + } catch (error) { + logger.error('Get dashboard analytics error:', error); + res.status(500).json({ + success: false, + message: 'Failed to get dashboard analytics', + }); + } +}; diff --git a/apps/api/src/domains/dashboard/dashboard.routes.ts b/apps/api/src/domains/dashboard/dashboard.routes.ts index c4d3dd5..bdfc582 100644 --- a/apps/api/src/domains/dashboard/dashboard.routes.ts +++ b/apps/api/src/domains/dashboard/dashboard.routes.ts @@ -21,4 +21,26 @@ router.get('/metrics', dashboardController.getSystemMetrics); // Get recent alerts router.get('/recent-alerts', dashboardController.getRecentAlerts); +// Dashboard Analytics Endpoints +// Get request trend (auto-refresh every 5s) +router.get('/analytics/request-trend', dashboardController.getRequestTrend); + +// Get slow requests +router.get('/analytics/slow-requests', dashboardController.getSlowRequests); + +// Get latest attack statistics (top 5 in 24h) +router.get('/analytics/latest-attacks', dashboardController.getLatestAttackStats); + +// Get latest security news/events +router.get('/analytics/latest-news', dashboardController.getLatestNews); + +// Get request analytics (top IPs by period) +router.get('/analytics/request-analytics', dashboardController.getRequestAnalytics); + +// Get attack vs normal request ratio +router.get('/analytics/attack-ratio', dashboardController.getAttackRatio); + +// Get complete dashboard analytics (all in one) +router.get('/analytics', dashboardController.getDashboardAnalytics); + export default router; diff --git a/apps/api/src/domains/dashboard/services/dashboard-analytics.service.ts b/apps/api/src/domains/dashboard/services/dashboard-analytics.service.ts new file mode 100644 index 0000000..4e7015b --- /dev/null +++ b/apps/api/src/domains/dashboard/services/dashboard-analytics.service.ts @@ -0,0 +1,588 @@ +/** + * Dashboard Analytics Service + * Handles advanced analytics and statistics from logs + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs/promises'; +import logger from '../../../utils/logger'; +import { + RequestTrendDataPoint, + SlowRequestEntry, + AttackTypeStats, + LatestAttackEntry, + IpAnalyticsEntry, + AttackRatioStats, + RequestAnalyticsResponse, +} from '../types/dashboard-analytics.types'; +import { parseAccessLogLine, parseModSecLogLine } from '../../logs/services/log-parser.service'; + +const execAsync = promisify(exec); + +const NGINX_ACCESS_LOG = '/var/log/nginx/access.log'; +const NGINX_ERROR_LOG = '/var/log/nginx/error.log'; +const MODSEC_AUDIT_LOG = '/var/log/modsec_audit.log'; +const NGINX_LOG_DIR = '/var/log/nginx'; +const MAX_BUFFER = 100 * 1024 * 1024; // 100MB +const HOURS_24 = 24 * 3600 * 1000; + +export class DashboardAnalyticsService { + /** + * Helper: Calculate cutoff time for a given period in hours + */ + private getCutoffTime(hours: number): number { + return Date.now() - hours * 3600 * 1000; + } + + /** + * Helper: Read ModSecurity logs from a single error log file + */ + private async readModSecFromFile(filePath: string): Promise { + try { + const { stdout } = await execAsync(`grep "ModSecurity:" ${filePath} 2>/dev/null || echo ""`, { maxBuffer: MAX_BUFFER }); + return stdout.trim().split('\n').filter(line => line.trim().length > 0); + } catch (error) { + logger.warn(`Could not read ModSec logs from ${filePath}:`, error); + return []; + } + } + + /** + * Helper: Read ALL ModSecurity logs from error.log (NO LINE LIMIT!) + */ + private async readModSecLogs(numLines: number): Promise { + const lines: string[] = []; + + // Read from main nginx error.log + lines.push(...await this.readModSecFromFile(NGINX_ERROR_LOG)); + + // Read from domain-specific error logs + try { + const domainLogs = await this.getDomainLogFiles(); + for (const domainLog of domainLogs) { + if (domainLog.errorLog) lines.push(...await this.readModSecFromFile(domainLog.errorLog)); + if (domainLog.sslErrorLog) lines.push(...await this.readModSecFromFile(domainLog.sslErrorLog)); + } + } catch (error) { + logger.error('Could not read from domain error logs:', error); + } + + return lines; + } + + /** + * Helper: Read access logs from all sources (main + domain-specific) + */ + private async readAllAccessLogs(mainLogLines: number, domainLogLines: number): Promise { + const lines = await this.readLastLines(NGINX_ACCESS_LOG, mainLogLines); + + const domainLogs = await this.getDomainLogFiles(); + for (const domainLog of domainLogs) { + if (domainLog.accessLog) lines.push(...await this.readLastLines(domainLog.accessLog, domainLogLines)); + if (domainLog.sslAccessLog) lines.push(...await this.readLastLines(domainLog.sslAccessLog, domainLogLines)); + } + + return lines; + } + + /** + * Helper: Determine attack type from parsed ModSec log + */ + private determineAttackType(parsed: any, defaultType: string = 'Unknown Attack'): string { + // Check tags first + if (parsed.tags && parsed.tags.length > 0) { + const meaningfulTag = parsed.tags.find((tag: string) => + tag.includes('attack') || tag.includes('injection') || tag.includes('xss') || + tag.includes('sqli') || tag.includes('rce') || tag.includes('lfi') || tag.includes('rfi') || tag.includes('anomaly-evaluation') + ); + if (meaningfulTag) { + return meaningfulTag.replace(/-/g, ' ').replace(/_/g, ' ').toUpperCase(); + } + } + + // Check message + if (parsed.message) { + const attackTypes: { [key: string]: string } = { + 'SQL Injection': 'SQL Injection', + 'XSS': defaultType === 'Unknown Attack' ? 'Cross-Site Scripting' : 'XSS Attack', + 'RCE': 'Remote Code Execution', + 'LFI': 'Local File Inclusion', + 'RFI': 'Remote File Inclusion', + 'Command Injection': 'Command Injection', + 'Anomaly Evaluation': 'Anomaly Evaluation' + }; + + for (const [key, value] of Object.entries(attackTypes)) { + if (parsed.message.includes(key)) return value; + } + } + + return defaultType; + } + + /** + * Helper: Increment status code counter + */ + private incrementStatusCode(dataPoint: RequestTrendDataPoint, status: number): void { + const statusKey = `status${status}` as keyof RequestTrendDataPoint; + if (statusKey in dataPoint) { + (dataPoint[statusKey] as number)++; + } else { + dataPoint.statusOther++; + } + } + + /** + * Get request trend data (auto-refresh every 5 seconds) + * Returns request count grouped by status codes over time + */ + async getRequestTrend(intervalSeconds: number = 5): Promise { + try { + // Get logs from the last 24 hours grouped by time intervals + const hoursToFetch = 24; + const dataPoints = Math.floor((hoursToFetch * 3600) / intervalSeconds); + const now = Date.now(); + + // Read access logs from all sources + const lines = await this.readAllAccessLogs(10000, 5000); + + // Parse logs and group by time intervals + const intervalMap = new Map(); + + lines.forEach((line, index) => { + const parsed = parseAccessLogLine(line, index); + if (!parsed) return; + + const timestamp = new Date(parsed.timestamp).getTime(); + const intervalIndex = Math.floor((now - timestamp) / (intervalSeconds * 1000)); + + if (intervalIndex >= dataPoints || intervalIndex < 0) return; + + const intervalKey = now - (intervalIndex * intervalSeconds * 1000); + + if (!intervalMap.has(intervalKey)) { + intervalMap.set(intervalKey, { + timestamp: new Date(intervalKey).toISOString(), + total: 0, + status200: 0, + status301: 0, + status302: 0, + status400: 0, + status403: 0, + status404: 0, + status500: 0, + status502: 0, + status503: 0, + statusOther: 0, + }); + } + + const dataPoint = intervalMap.get(intervalKey)!; + dataPoint.total++; + + // Count by status code + if (parsed.statusCode) { + this.incrementStatusCode(dataPoint, parsed.statusCode); + } + }); + + // Convert to array and sort by timestamp + const result = Array.from(intervalMap.values()) + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + return result; + } catch (error) { + logger.error('Get request trend error:', error); + return []; + } + } + + /** + * Get top 10 slow requests from performance monitoring + */ + async getSlowRequests(limit: number = 10): Promise { + try { + // Get from PerformanceMetric table + const prisma = (await import('../../../config/database')).default; + + const slowRequests = await prisma.performanceMetric.groupBy({ + by: ['domain'], + _avg: { + responseTime: true, + }, + _max: { + responseTime: true, + }, + _min: { + responseTime: true, + }, + _count: { + domain: true, + }, + orderBy: { + _avg: { + responseTime: 'desc', + }, + }, + take: limit, + where: { + timestamp: { + gte: new Date(Date.now() - 24 * 3600 * 1000), // Last 24 hours + }, + }, + }); + + return slowRequests.map(item => ({ + path: item.domain, + avgResponseTime: item._avg.responseTime || 0, + maxResponseTime: item._max.responseTime || 0, + minResponseTime: item._min.responseTime || 0, + requestCount: item._count.domain, + })); + } catch (error) { + logger.error('Get slow requests error:', error); + return []; + } + } + + /** + * Get top 5 attack types in last 24 hours + */ + async getLatestAttacks(limit: number = 5): Promise { + try { + // Read ModSecurity logs from error.log and audit log + const lines = await this.readModSecLogs(5000); + + // Parse and group by attack type + const attackMap = new Map; + }>(); + + const cutoffTime = this.getCutoffTime(24); + + lines.forEach((line, index) => { + const parsed = parseModSecLogLine(line, index); + if (!parsed || !parsed.ruleId) return; + + const timestamp = new Date(parsed.timestamp).getTime(); + if (timestamp < cutoffTime) return; + + const attackType = this.determineAttackType(parsed); + + if (!attackMap.has(attackType)) { + attackMap.set(attackType, { + count: 0, + severity: parsed.severity || 'MEDIUM', + lastOccurred: parsed.timestamp, + ruleIds: new Set(), + }); + } + + const stats = attackMap.get(attackType)!; + stats.count++; + if (parsed.ruleId) stats.ruleIds.add(parsed.ruleId); + + // Update last occurred if more recent + if (new Date(parsed.timestamp) > new Date(stats.lastOccurred)) { + stats.lastOccurred = parsed.timestamp; + } + }); + + // Convert to array and sort by count + const result: AttackTypeStats[] = Array.from(attackMap.entries()) + .map(([attackType, stats]) => ({ + attackType, + count: stats.count, + severity: stats.severity, + lastOccurred: stats.lastOccurred, + timestamp: stats.lastOccurred, + ruleIds: Array.from(stats.ruleIds), + })) + .sort((a, b) => b.count - a.count) + .slice(0, limit); + + return result; + } catch (error) { + logger.error('Get latest attacks error:', error); + return []; + } + } + + /** + * Get latest security news/events (table format) + */ + async getLatestNews(limit: number = 20): Promise { + try { + // Read ModSecurity logs from error logs only (not audit log - different format) + const lines = await this.readModSecLogs(2000); + + const attacks: LatestAttackEntry[] = []; + const cutoffTime = this.getCutoffTime(24); + + lines.forEach((line, index) => { + const parsed = parseModSecLogLine(line, index); + if (!parsed) return; + + const timestamp = new Date(parsed.timestamp).getTime(); + if (timestamp < cutoffTime) return; + + const attackerIp = parsed.ip || 'Unknown'; + const domain = parsed.hostname; + const attackType = this.determineAttackType(parsed, 'Security Event'); + + // Use ruleId as logId for better searching + const logId = parsed.ruleId || parsed.uniqueId || parsed.id; + + attacks.push({ + id: parsed.id, + timestamp: parsed.timestamp, + attackerIp, + domain, + urlPath: parsed.path || parsed.uri || '/', + attackType, + ruleId: parsed.ruleId, + uniqueId: parsed.uniqueId, // Add uniqueId for precise log lookup + severity: parsed.severity, + action: 'Blocked', + logId, + // DEBUG: Add raw log sample for first few entries + ...(index < 3 ? { _debugRawLog: line.substring(0, 300) } : {}), + } as any); + }); + + // Sort by timestamp descending and limit + return attacks + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, limit); + } catch (error) { + logger.error('Get latest news error:', error); + return []; + } + } + + /** + * Get request analytics (top IPs by period) + */ + async getRequestAnalytics(period: 'day' | 'week' | 'month' = 'day'): Promise { + try { + const periodHours = period === 'day' ? 24 : period === 'week' ? 168 : 720; + const cutoffTime = this.getCutoffTime(periodHours); + + // Read access logs from all sources + const lines = await this.readAllAccessLogs(20000, 10000); + + // Group by IP + const ipMap = new Map(); + + lines.forEach((line, index) => { + const parsed = parseAccessLogLine(line, index); + if (!parsed || !parsed.ip) return; + + const timestamp = new Date(parsed.timestamp).getTime(); + if (timestamp < cutoffTime) return; + + if (!ipMap.has(parsed.ip)) { + ipMap.set(parsed.ip, { + ip: parsed.ip, + requestCount: 0, + errorCount: 0, + attackCount: 0, + lastSeen: parsed.timestamp, + }); + } + + const entry = ipMap.get(parsed.ip)!; + entry.requestCount++; + + if (parsed.statusCode && parsed.statusCode >= 400) { + entry.errorCount++; + } + + // Update last seen + if (new Date(parsed.timestamp) > new Date(entry.lastSeen)) { + entry.lastSeen = parsed.timestamp; + } + }); + + // Check for attacks from ModSecurity logs - count by actual client IP + let modsecLines: string[] = []; + try { + modsecLines = await this.readModSecLogs(10000); + } catch (error) { + logger.error('Failed to read ModSec logs:', error); + } + + modsecLines.forEach((line, index) => { + const parsed = parseModSecLogLine(line, index); + if (!parsed) return; + + const timestamp = new Date(parsed.timestamp).getTime(); + if (timestamp < cutoffTime) return; + + // Use parsed IP (already extracted correctly from [client IP]) + const attackerIp = parsed.ip; + if (!attackerIp) return; + + // If IP exists in map, increment attack count + let entry = ipMap.get(attackerIp); + if (entry) { + entry.attackCount++; + entry.requestCount++; // Attacks are also requests! + } else { + // Create new entry for this IP if not exists + ipMap.set(attackerIp, { + ip: attackerIp, + requestCount: 1, // Attack is a request + errorCount: 1, // Attack is also an error + attackCount: 1, + lastSeen: parsed.timestamp, + }); + } + }); + + // Sort by request count and get top 10 + const topIps = Array.from(ipMap.values()) + .sort((a, b) => b.requestCount - a.requestCount) + .slice(0, 10); + + return { + period, + topIps, + totalRequests: lines.length, + uniqueIps: ipMap.size, + _timestamp: Date.now(), // Force cache refresh + } as any; + } catch (error) { + logger.error('Get request analytics error:', error); + return { + period, + topIps: [], + totalRequests: 0, + uniqueIps: 0, + }; + } + } + + /** + * Get attack vs normal request ratio + */ + async getAttackRatio(): Promise { + try { + // Count total requests from access logs (last 24h) + const accessLines = await this.readAllAccessLogs(20000, 10000); + const cutoffTime = this.getCutoffTime(24); + let totalRequests = 0; + + accessLines.forEach((line, index) => { + const parsed = parseAccessLogLine(line, index); + if (!parsed) return; + + const timestamp = new Date(parsed.timestamp).getTime(); + if (timestamp >= cutoffTime) { + totalRequests++; + } + }); + + // Count attack requests from ModSecurity logs + const modsecLines = await this.readModSecLogs(5000); + let attackRequests = 0; + + modsecLines.forEach((line, index) => { + const parsed = parseModSecLogLine(line, index); + if (!parsed) return; + + const timestamp = new Date(parsed.timestamp).getTime(); + if (timestamp >= cutoffTime) { + attackRequests++; + } + }); + + const normalRequests = totalRequests - attackRequests; + const attackPercentage = totalRequests > 0 ? (attackRequests / totalRequests) * 100 : 0; + + return { + totalRequests, + attackRequests, + normalRequests, + attackPercentage: parseFloat(attackPercentage.toFixed(2)), + }; + } catch (error) { + logger.error('Get attack ratio error:', error); + return { + totalRequests: 0, + attackRequests: 0, + normalRequests: 0, + attackPercentage: 0, + }; + } + } + + /** + * Helper: Read last N lines from file + */ + private async readLastLines(filePath: string, numLines: number): Promise { + try { + await fs.access(filePath); + const { stdout } = await execAsync(`tail -n ${numLines} ${filePath} 2>/dev/null || echo ""`); + return stdout.trim().split('\n').filter((line: string) => line.trim().length > 0); + } catch (error: any) { + if (error.code !== 'ENOENT') { + logger.warn(`Could not read log file ${filePath}:`, error); + } + return []; + } + } + + /** + * Helper: Get domain-specific log files + */ + private async getDomainLogFiles(): Promise<{ domain: string; accessLog: string; errorLog: string; sslAccessLog: string; sslErrorLog: string }[]> { + try { + const files = await fs.readdir(NGINX_LOG_DIR); + const domainLogs: { [key: string]: { accessLog?: string; errorLog?: string; sslAccessLog?: string; sslErrorLog?: string } } = {}; + + files.forEach(file => { + const sslAccessMatch = file.match(/^(.+?)[-_]ssl[-_]access\.log$/); + const sslErrorMatch = file.match(/^(.+?)[-_]ssl[-_]error\.log$/); + const accessMatch = !file.includes('ssl') && file.match(/^(.+?)[-_]access\.log$/); + const errorMatch = !file.includes('ssl') && file.match(/^(.+?)[-_]error\.log$/); + + if (sslAccessMatch) { + const domain = sslAccessMatch[1]; + if (!domainLogs[domain]) domainLogs[domain] = {}; + domainLogs[domain].sslAccessLog = `${NGINX_LOG_DIR}/${file}`; + } else if (sslErrorMatch) { + const domain = sslErrorMatch[1]; + if (!domainLogs[domain]) domainLogs[domain] = {}; + domainLogs[domain].sslErrorLog = `${NGINX_LOG_DIR}/${file}`; + } else if (accessMatch) { + const domain = accessMatch[1]; + if (!domainLogs[domain]) domainLogs[domain] = {}; + domainLogs[domain].accessLog = `${NGINX_LOG_DIR}/${file}`; + } else if (errorMatch) { + const domain = errorMatch[1]; + if (!domainLogs[domain]) domainLogs[domain] = {}; + domainLogs[domain].errorLog = `${NGINX_LOG_DIR}/${file}`; + } + }); + + return Object.entries(domainLogs).map(([domain, logs]) => ({ + domain, + accessLog: logs.accessLog || '', + errorLog: logs.errorLog || '', + sslAccessLog: logs.sslAccessLog || '', + sslErrorLog: logs.sslErrorLog || '', + })); + } catch (error) { + logger.error('Error reading domain log files:', error); + return []; + } + } +} + +// Export singleton instance +export const dashboardAnalyticsService = new DashboardAnalyticsService(); diff --git a/apps/api/src/domains/dashboard/types/dashboard-analytics.types.ts b/apps/api/src/domains/dashboard/types/dashboard-analytics.types.ts new file mode 100644 index 0000000..241488c --- /dev/null +++ b/apps/api/src/domains/dashboard/types/dashboard-analytics.types.ts @@ -0,0 +1,105 @@ +/** + * Dashboard Analytics Types + * Types for advanced dashboard statistics and analytics + */ + +// Base interfaces +interface BaseCountStats { + count: number; +} + +interface TimestampedEntry { + timestamp: string; +} + +interface ResponseTimeMetrics { + requestCount: number; + avgResponseTime: number; + maxResponseTime: number; + minResponseTime: number; +} + +// Status code fields interface +interface StatusCodeFields { + status200: number; + status301: number; + status302: number; + status400: number; + status403: number; + status404: number; + status500: number; + status502: number; + status503: number; + statusOther: number; +} + +// Request trend data point +export interface RequestTrendDataPoint extends TimestampedEntry, StatusCodeFields { + total: number; +} + +// Slow request entry +export interface SlowRequestEntry extends ResponseTimeMetrics { + path: string; +} + +// Attack type statistics +export interface AttackTypeStats extends BaseCountStats, TimestampedEntry { + attackType: string; + severity: string; + lastOccurred: string; + ruleIds: string[]; +} + +// Latest attack/security event +export interface LatestAttackEntry extends TimestampedEntry { + id: string; + attackerIp: string; + domain?: string; + urlPath: string; + attackType: string; + ruleId?: string; + uniqueId?: string; + severity?: string; + action: string; + logId: string; +} + +// IP analytics entry +export interface IpAnalyticsEntry { + ip: string; + requestCount: number; + errorCount: number; + attackCount: number; + lastSeen: string; + userAgent?: string; +} + +// Attack vs Normal request ratio +export interface AttackRatioStats { + totalRequests: number; + attackRequests: number; + normalRequests: number; + attackPercentage: number; +} + +// Period type for analytics +export type AnalyticsPeriod = 'day' | 'week' | 'month'; + +// Request analytics response +export interface RequestAnalyticsResponse { + period: AnalyticsPeriod; + topIps: IpAnalyticsEntry[]; + totalRequests: number; + uniqueIps: number; +} + +// Complete dashboard analytics response +export interface DashboardAnalyticsResponse { + requestTrend: RequestTrendDataPoint[]; + slowRequests: SlowRequestEntry[]; + latestAttacks: AttackTypeStats[]; + latestNews: LatestAttackEntry[]; + requestAnalytics: RequestAnalyticsResponse; + attackRatio: AttackRatioStats; +} diff --git a/apps/api/src/domains/domains/domains.controller.ts b/apps/api/src/domains/domains/domains.controller.ts index f2146db..516b06c 100644 --- a/apps/api/src/domains/domains/domains.controller.ts +++ b/apps/api/src/domains/domains/domains.controller.ts @@ -124,6 +124,17 @@ export class DomainsController { logger.error('Create domain error:', error); if (error.message === 'Domain already exists') { + res.status(409).json({ + success: false, + message: error.message, + }); + return; + } + + // Handle nginx validation errors + if (error.message.includes('Nginx configuration validation failed') || + error.message.includes('Nginx reload failed') || + error.message.includes('Invalid nginx configuration')) { res.status(400).json({ success: false, message: error.message, @@ -133,7 +144,7 @@ export class DomainsController { res.status(500).json({ success: false, - message: 'Internal server error', + message: error.message || 'Internal server error', }); } } @@ -188,9 +199,20 @@ export class DomainsController { return; } + // Handle nginx validation errors + if (error.message.includes('Nginx configuration validation failed') || + error.message.includes('Nginx reload failed') || + error.message.includes('Invalid nginx configuration')) { + res.status(400).json({ + success: false, + message: error.message, + }); + return; + } + res.status(500).json({ success: false, - message: 'Internal server error', + message: error.message || 'Internal server error', }); } } diff --git a/apps/api/src/domains/domains/domains.service.ts b/apps/api/src/domains/domains/domains.service.ts index c114062..bd4e867 100644 --- a/apps/api/src/domains/domains/domains.service.ts +++ b/apps/api/src/domains/domains/domains.service.ts @@ -51,31 +51,55 @@ export class DomainsService { // Create domain const domain = await domainsRepository.create(input); - // Generate nginx configuration - await nginxConfigService.generateConfig(domain); + try { + // Generate nginx configuration (includes validation) + await nginxConfigService.generateConfig(domain); - // Update domain status to active - const updatedDomain = await domainsRepository.updateStatus(domain.id, 'active'); + // Update domain status to active + const updatedDomain = await domainsRepository.updateStatus(domain.id, 'active'); - // Enable configuration - await nginxConfigService.enableConfig(domain.name); + // Enable configuration + await nginxConfigService.enableConfig(domain.name); - // Auto-reload nginx (silent mode) - await nginxReloadService.autoReload(true); + // Auto-reload nginx + const reloadResult = await nginxReloadService.reload(); + if (!reloadResult.success) { + // Rollback: delete domain and config + await nginxConfigService.deleteConfig(domain.name); + await domainsRepository.delete(domain.id); + throw new Error(`Nginx reload failed: ${reloadResult.error || 'Unknown error'}`); + } - // Log activity - await this.logActivity( - userId, - `Created domain: ${input.name}`, - 'config_change', - ip, - userAgent, - true - ); - - logger.info(`Domain ${input.name} created by user ${username}`); + // Log activity + await this.logActivity( + userId, + `Created domain: ${input.name}`, + 'config_change', + ip, + userAgent, + true + ); - return updatedDomain; + logger.info(`Domain ${input.name} created by user ${username}`); + + return updatedDomain; + } catch (error: any) { + // Rollback: delete domain from database + logger.error(`Failed to create domain ${input.name}, rolling back:`, error); + try { + await nginxConfigService.deleteConfig(domain.name); + await domainsRepository.delete(domain.id); + logger.info(`Rolled back domain creation for ${input.name}`); + } catch (rollbackError) { + logger.error(`Failed to rollback domain creation:`, rollbackError); + } + + // Re-throw with user-friendly message + if (error.message.includes('Invalid nginx configuration')) { + throw new Error(`Nginx configuration validation failed: ${error.message}`); + } + throw error; + } } /** @@ -110,39 +134,92 @@ export class DomainsService { userAgent: string ): Promise { // Check if domain exists - const domain = await domainsRepository.findById(id); - if (!domain) { + const originalDomain = await domainsRepository.findById(id); + if (!originalDomain) { throw new Error('Domain not found'); } - // Update domain - await domainsRepository.update(id, input); - - // Get updated domain with relations - const updatedDomain = await domainsRepository.findById(id); - if (!updatedDomain) { - throw new Error('Failed to fetch updated domain'); - } + // Store original data for rollback + const originalData: UpdateDomainInput = { + name: originalDomain.name, + status: originalDomain.status, + modsecEnabled: originalDomain.modsecEnabled, + upstreams: originalDomain.upstreams.map(u => ({ + host: u.host, + port: u.port, + protocol: u.protocol, + sslVerify: u.sslVerify, + weight: u.weight, + maxFails: u.maxFails, + failTimeout: u.failTimeout, + })), + loadBalancer: originalDomain.loadBalancer ? { + algorithm: originalDomain.loadBalancer.algorithm, + healthCheckEnabled: originalDomain.loadBalancer.healthCheckEnabled, + healthCheckInterval: originalDomain.loadBalancer.healthCheckInterval, + healthCheckTimeout: originalDomain.loadBalancer.healthCheckTimeout, + healthCheckPath: originalDomain.loadBalancer.healthCheckPath, + } : undefined, + }; - // Regenerate nginx config - await nginxConfigService.generateConfig(updatedDomain); - - // Auto-reload nginx - await nginxReloadService.autoReload(true); - - // Log activity - await this.logActivity( - userId, - `Updated domain: ${updatedDomain.name}`, - 'config_change', - ip, - userAgent, - true - ); + try { + // Update domain + await domainsRepository.update(id, input); + + // Get updated domain with relations + const updatedDomain = await domainsRepository.findById(id); + if (!updatedDomain) { + throw new Error('Failed to fetch updated domain'); + } + + // Regenerate nginx config (includes validation and backup) + await nginxConfigService.generateConfig(updatedDomain); + + // Auto-reload nginx + const reloadResult = await nginxReloadService.reload(); + if (!reloadResult.success) { + // Rollback: restore original domain data + await domainsRepository.update(id, originalData); + const restoredDomain = await domainsRepository.findById(id); + if (restoredDomain) { + await nginxConfigService.generateConfig(restoredDomain); + } + throw new Error(`Nginx reload failed: ${reloadResult.error || 'Unknown error'}`); + } - logger.info(`Domain ${updatedDomain.name} updated by user ${username}`); + // Log activity + await this.logActivity( + userId, + `Updated domain: ${updatedDomain.name}`, + 'config_change', + ip, + userAgent, + true + ); - return updatedDomain; + logger.info(`Domain ${updatedDomain.name} updated by user ${username}`); + + return updatedDomain; + } catch (error: any) { + // Rollback: restore original domain data + logger.error(`Failed to update domain ${originalDomain.name}, rolling back:`, error); + try { + await domainsRepository.update(id, originalData); + const restoredDomain = await domainsRepository.findById(id); + if (restoredDomain) { + await nginxConfigService.generateConfig(restoredDomain); + } + logger.info(`Rolled back domain update for ${originalDomain.name}`); + } catch (rollbackError) { + logger.error(`Failed to rollback domain update:`, rollbackError); + } + + // Re-throw with user-friendly message + if (error.message.includes('Invalid nginx configuration')) { + throw new Error(`Nginx configuration validation failed: ${error.message}`); + } + throw error; + } } /** diff --git a/apps/api/src/domains/logs/logs.controller.ts b/apps/api/src/domains/logs/logs.controller.ts index 45f9c42..5ef2c0d 100644 --- a/apps/api/src/domains/logs/logs.controller.ts +++ b/apps/api/src/domains/logs/logs.controller.ts @@ -3,6 +3,20 @@ import { AuthRequest } from '../../middleware/auth'; import logger from '../../utils/logger'; import { getParsedLogs, getLogStats, getAvailableDomainsFromDb } from './logs.service'; +// Constants for security limits +const MAX_LOGS_PER_REQUEST = 100; +const MAX_DOWNLOAD_LOGS = 5000; +const MAX_TOTAL_LOGS_FETCH = 5000; + +/** + * Sanitize string input to prevent injection + */ +const sanitizeString = (input: string | undefined): string | undefined => { + if (!input) return undefined; + // Remove any potential SQL injection or XSS characters + return input.toString().trim().substring(0, 200); +}; + /** * Get logs with filters */ @@ -11,37 +25,54 @@ export const getLogs = async ( res: Response ): Promise => { try { - const { limit = '10', page = '1', level, type, search, domain } = req.query; + const { limit = '10', page = '1', level, type, search, domain, ruleId, uniqueId } = req.query; // Parse and validate parameters const limitNum = Math.min( Math.max(parseInt(limit as string) || 10, 1), - 100 - ); // Between 1 and 100 - const pageNum = Math.max(parseInt(page as string) || 1, 1); // At least 1 - - // Get all logs first to calculate total + MAX_LOGS_PER_REQUEST + ); + const pageNum = Math.max(parseInt(page as string) || 1, 1); + + // Sanitize all string inputs + const sanitizedLevel = sanitizeString(level as string); + const sanitizedType = sanitizeString(type as string); + const sanitizedSearch = sanitizeString(search as string); + const sanitizedDomain = sanitizeString(domain as string); + const sanitizedRuleId = sanitizeString(ruleId as string); + const sanitizedUniqueId = sanitizeString(uniqueId as string); + + // Calculate offset for efficient database query + const offset = (pageNum - 1) * limitNum; + + // Get logs with pagination limit (fetch only what's needed + 1 for hasMore check) + const fetchLimit = Math.min(limitNum + 1, MAX_TOTAL_LOGS_FETCH); + const allLogs = await getParsedLogs({ - limit: 10000, // Get a large number to calculate total - level: level as string, - type: type as string, - search: search as string, - domain: domain as string, + limit: fetchLimit, + offset: offset, + level: sanitizedLevel, + type: sanitizedType, + search: sanitizedSearch, + domain: sanitizedDomain, + ruleId: sanitizedRuleId, + uniqueId: sanitizedUniqueId, }); - // Calculate pagination info - const total = allLogs.length; - const totalPages = Math.ceil(total / limitNum); - const startIndex = (pageNum - 1) * limitNum; - const endIndex = startIndex + limitNum; + // Check if there are more results + const hasMore = allLogs.length > limitNum; + const paginatedLogs = hasMore ? allLogs.slice(0, limitNum) : allLogs; - // Get the paginated logs by slicing the allLogs array - const paginatedLogs = allLogs.slice(startIndex, endIndex); + // For total count, we need a separate count query (more efficient than fetching all) + // This should be implemented in the service layer + // For now, we'll use a reasonable approach + const total = allLogs.length; + const totalPages = hasMore ? pageNum + 1 : pageNum; // At minimum logger.info( - `User ${req.user?.username} fetched ${ - paginatedLogs.length - } logs (page ${pageNum})${domain ? ` for domain ${domain}` : ''}` + `User fetched ${paginatedLogs.length} logs (page ${pageNum})${ + sanitizedDomain ? ` for domain ${sanitizedDomain}` : '' + }` ); res.json({ @@ -50,15 +81,19 @@ export const getLogs = async ( pagination: { page: pageNum, limit: limitNum, - total, - totalPages, + total: total, + totalPages: totalPages, + hasMore: hasMore, }, }); } catch (error) { - logger.error('Get logs error:', error); + logger.error('Get logs error:', { + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined, + }); res.status(500).json({ success: false, - message: 'Internal server error', + message: 'Unable to retrieve logs', }); } }; @@ -78,10 +113,12 @@ export const getLogStatistics = async ( data: stats, }); } catch (error) { - logger.error('Get log statistics error:', error); + logger.error('Get log statistics error:', { + error: error instanceof Error ? error.message : 'Unknown error', + }); res.status(500).json({ success: false, - message: 'Internal server error', + message: 'Unable to retrieve statistics', }); } }; @@ -94,30 +131,41 @@ export const downloadLogs = async ( res: Response ): Promise => { try { - const { limit = '1000', level, type, search, domain } = req.query; + const { limit = '1000', level, type, search, domain, ruleId, uniqueId } = req.query; - // Parse and validate parameters + // Parse and validate parameters with stricter limit const limitNum = Math.min( Math.max(parseInt(limit as string) || 1000, 1), - 10000 - ); // Between 1 and 10000 + MAX_DOWNLOAD_LOGS + ); + + // Sanitize all string inputs + const sanitizedLevel = sanitizeString(level as string); + const sanitizedType = sanitizeString(type as string); + const sanitizedSearch = sanitizeString(search as string); + const sanitizedDomain = sanitizeString(domain as string); + const sanitizedRuleId = sanitizeString(ruleId as string); + const sanitizedUniqueId = sanitizeString(uniqueId as string); const logs = await getParsedLogs({ limit: limitNum, - level: level as string, - type: type as string, - search: search as string, - domain: domain as string, + level: sanitizedLevel, + type: sanitizedType, + search: sanitizedSearch, + domain: sanitizedDomain, + ruleId: sanitizedRuleId, + uniqueId: sanitizedUniqueId, }); logger.info( - `User ${req.user?.username} downloaded ${logs.length} logs${ - domain ? ` for domain ${domain}` : '' + `User downloaded ${logs.length} logs${ + sanitizedDomain ? ` for domain ${sanitizedDomain}` : '' }` ); // Set headers for file download - const filename = `logs-${new Date().toISOString()}.json`; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `logs-${timestamp}.json`; res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); @@ -126,21 +174,24 @@ export const downloadLogs = async ( data: logs, metadata: { exportedAt: new Date().toISOString(), - exportedBy: req.user?.username, totalCount: logs.length, filters: { - level, - type, - search, - domain, + level: sanitizedLevel, + type: sanitizedType, + search: sanitizedSearch, + domain: sanitizedDomain, + ruleId: sanitizedRuleId, + uniqueId: sanitizedUniqueId, }, }, }); } catch (error) { - logger.error('Download logs error:', error); + logger.error('Download logs error:', { + error: error instanceof Error ? error.message : 'Unknown error', + }); res.status(500).json({ success: false, - message: 'Internal server error', + message: 'Unable to download logs', }); } }; @@ -160,10 +211,12 @@ export const getAvailableDomains = async ( data: domains, }); } catch (error) { - logger.error('Get available domains error:', error); + logger.error('Get available domains error:', { + error: error instanceof Error ? error.message : 'Unknown error', + }); res.status(500).json({ success: false, - message: 'Internal server error', + message: 'Unable to retrieve domains', }); } -}; +}; \ No newline at end of file diff --git a/apps/api/src/domains/logs/logs.service.ts b/apps/api/src/domains/logs/logs.service.ts index c21dd22..3516f43 100644 --- a/apps/api/src/domains/logs/logs.service.ts +++ b/apps/api/src/domains/logs/logs.service.ts @@ -1,89 +1,243 @@ import * as fs from 'fs/promises'; import * as path from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; import logger from '../../utils/logger'; import prisma from '../../config/database'; import { ParsedLogEntry, LogFilterOptions, LogStatistics } from './logs.types'; import { parseAccessLogLine, parseErrorLogLine, parseModSecLogLine } from './services/log-parser.service'; -const NGINX_ACCESS_LOG = '/var/log/nginx/access.log'; -const NGINX_ERROR_LOG = '/var/log/nginx/error.log'; -const MODSEC_AUDIT_LOG = '/var/log/modsec_audit.log'; -const NGINX_LOG_DIR = '/var/log/nginx'; +const execFileAsync = promisify(execFile); + +// Log file paths +const LOG_PATHS = { + nginxAccess: '/var/log/nginx/access.log', + nginxError: '/var/log/nginx/error.log', + modsecAudit: '/var/log/modsec_audit.log', + nginxDir: '/var/log/nginx', +} as const; + +// Security constants +const SECURITY_LIMITS = { + maxLinesPerFile: 1000, + maxConcurrentFiles: 5, + maxUniqueIdLength: 64, + maxSearchTermLength: 200, + maxRuleIdLength: 100, + execTimeout: 5000, + grepTimeout: 10000, + maxBuffer: 10 * 1024 * 1024, // 10MB +} as const; + +const ALLOWED_LOG_DIR = path.resolve(LOG_PATHS.nginxDir); + +// Domain regex for validation +const DOMAIN_REGEX = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + +// Log file naming patterns +const LOG_PATTERNS = { + sslAccess: /^([a-zA-Z0-9.-]+)[-_]ssl[-_]access\.log$/, + sslError: /^([a-zA-Z0-9.-]+)[-_]ssl[-_]error\.log$/, + access: /^([a-zA-Z0-9.-]+)[-_]access\.log$/, + error: /^([a-zA-Z0-9.-]+)[-_]error\.log$/, +} as const; /** - * Read last N lines from a file efficiently + * Validate and sanitize domain name + */ +function sanitizeDomain(domain: string): string | null { + if (!domain || typeof domain !== 'string') return null; + + const cleaned = domain.trim().replace(/[^a-zA-Z0-9.-]/g, ''); + return DOMAIN_REGEX.test(cleaned) && cleaned.length <= 253 ? cleaned : null; +} + +/** + * Validate file path is within allowed directory + */ +function isPathSafe(filePath: string): boolean { + return path.resolve(filePath).startsWith(ALLOWED_LOG_DIR); +} + +/** + * Execute command with security measures + */ +async function safeExecFile( + command: string, + args: string[], + options: { input?: string; timeout?: number } = {} +): Promise { + const { input, timeout = SECURITY_LIMITS.execTimeout } = options; + + try { + const { stdout } = await execFileAsync(command, args, { + timeout, + maxBuffer: SECURITY_LIMITS.maxBuffer, + encoding: 'utf8', + ...(input && { input }), + }); + return stdout.trim(); + } catch (error: any) { + // grep exit code 1 means no matches - this is normal + if (error.code === 1) return ''; + if (error.killed) { + logger.warn(`Command timed out: ${command} ${args.join(' ')}`); + } + throw error; + } +} + +/** + * Read last N lines from a file with security checks */ async function readLastLines(filePath: string, numLines: number): Promise { try { - await fs.access(filePath); + if (!isPathSafe(filePath)) { + logger.warn(`Path validation failed: ${filePath}`); + return []; + } - // Use tail command for efficiency with large files - const { exec } = require('child_process'); - const { promisify } = require('util'); - const execAsync = promisify(exec); + const safeNumLines = Math.min(Math.max(numLines, 1), SECURITY_LIMITS.maxLinesPerFile); + await fs.access(filePath); - const { stdout } = await execAsync(`tail -n ${numLines} ${filePath} 2>/dev/null || echo ""`); - return stdout.trim().split('\n').filter((line: string) => line.trim().length > 0); + const stdout = await safeExecFile('tail', ['-n', String(safeNumLines), filePath]); + return stdout.split('\n').filter(line => line.trim().length > 0); } catch (error: any) { if (error.code === 'ENOENT') { - logger.warn(`Log file not found: ${filePath}`); + logger.debug(`Log file not found: ${filePath}`); } else { - logger.error(`Error reading log file ${filePath}:`, error); + logger.error(`Error reading log file: ${error.message}`); } return []; } } /** - * Get list of domain-specific log files + * Parse log lines with appropriate parser + */ +interface ParseOptions { + parser: 'access' | 'error' | 'modsec'; + domain?: string; +} + +function parseLogLines( + lines: string[], + { parser, domain }: ParseOptions +): ParsedLogEntry[] { + const parsers = { + access: (line: string, idx: number) => parseAccessLogLine(line, idx, domain), + error: (line: string, idx: number) => { + const parsed = parseErrorLogLine(line, idx); + if (parsed && domain) parsed.domain = domain; + return parsed; + }, + modsec: (line: string, idx: number) => { + const parsed = parseModSecLogLine(line, idx); + if (parsed && domain) parsed.domain = domain; + return parsed; + }, + }; + + return lines + .map((line, idx) => parsers[parser](line, idx)) + .filter((entry): entry is ParsedLogEntry => entry !== null); +} + +/** + * Search logs using grep + */ +async function grepLogFile( + filePath: string, + pattern: string, + limit: number +): Promise { + if (!isPathSafe(filePath)) return ''; + + try { + const grepResult = await safeExecFile( + 'grep', + ['-F', pattern, filePath], + { timeout: SECURITY_LIMITS.grepTimeout } + ); + + if (!grepResult) return ''; + + return await safeExecFile( + 'head', + ['-n', String(limit)], + { input: grepResult, timeout: SECURITY_LIMITS.execTimeout } + ); + } catch (error: any) { + if (error.code !== 1 && !error.killed) { + logger.debug(`Grep failed for ${filePath}: ${error.message}`); + } + return ''; + } +} + +/** + * Get domain log file paths */ -async function getDomainLogFiles(): Promise<{ domain: string; accessLog: string; errorLog: string; sslAccessLog: string; sslErrorLog: string }[]> { +interface DomainLogFiles { + domain: string; + accessLog: string; + errorLog: string; + sslAccessLog: string; + sslErrorLog: string; +} + +async function getDomainLogFiles(): Promise { try { - const files = await fs.readdir(NGINX_LOG_DIR); - const domainLogs: { [key: string]: { accessLog?: string; errorLog?: string; sslAccessLog?: string; sslErrorLog?: string } } = {}; + if (!isPathSafe(LOG_PATHS.nginxDir)) { + logger.error('Log directory validation failed'); + return []; + } + + const files = await fs.readdir(LOG_PATHS.nginxDir); + logger.info(`Found ${files.length} files in ${LOG_PATHS.nginxDir}`); + const domainLogs: Record>> = {}; files.forEach(file => { - // Match patterns for both HTTP and HTTPS logs: - // - example.com_access.log or example.com-access.log (HTTP) - // - example.com_error.log or example.com-error.log (HTTP) - // - example.com_ssl_access.log or example.com-ssl-access.log (HTTPS) - // - example.com_ssl_error.log or example.com-ssl-error.log (HTTPS) - - // SSL access log - const sslAccessMatch = file.match(/^(.+?)[-_]ssl[-_]access\.log$/); - // SSL error log - const sslErrorMatch = file.match(/^(.+?)[-_]ssl[-_]error\.log$/); - // Non-SSL access log (must not contain 'ssl') - const accessMatch = !file.includes('ssl') && file.match(/^(.+?)[-_]access\.log$/); - // Non-SSL error log (must not contain 'ssl') - const errorMatch = !file.includes('ssl') && file.match(/^(.+?)[-_]error\.log$/); - - if (sslAccessMatch) { - const domain = sslAccessMatch[1]; - if (!domainLogs[domain]) domainLogs[domain] = {}; - domainLogs[domain].sslAccessLog = path.join(NGINX_LOG_DIR, file); - } else if (sslErrorMatch) { - const domain = sslErrorMatch[1]; - if (!domainLogs[domain]) domainLogs[domain] = {}; - domainLogs[domain].sslErrorLog = path.join(NGINX_LOG_DIR, file); - } else if (accessMatch) { - const domain = accessMatch[1]; - if (!domainLogs[domain]) domainLogs[domain] = {}; - domainLogs[domain].accessLog = path.join(NGINX_LOG_DIR, file); - } else if (errorMatch) { - const domain = errorMatch[1]; - if (!domainLogs[domain]) domainLogs[domain] = {}; - domainLogs[domain].errorLog = path.join(NGINX_LOG_DIR, file); + // Skip hidden files and parent directory references + if (file.startsWith('.') || file.includes('..')) return; + + const fullPath = path.join(LOG_PATHS.nginxDir, file); + if (!isPathSafe(fullPath)) return; + + // Match against patterns + const patterns = [ + { regex: LOG_PATTERNS.sslAccess, key: 'sslAccessLog', requireNoSSL: false }, + { regex: LOG_PATTERNS.sslError, key: 'sslErrorLog', requireNoSSL: false }, + { regex: LOG_PATTERNS.access, key: 'accessLog', requireNoSSL: true }, + { regex: LOG_PATTERNS.error, key: 'errorLog', requireNoSSL: true }, + ] as const; + + for (const { regex, key, requireNoSSL } of patterns) { + if (requireNoSSL && file.includes('ssl')) continue; + + const match = file.match(regex); + if (match) { + const domain = sanitizeDomain(match[1]); + if (domain) { + if (!domainLogs[domain]) domainLogs[domain] = {}; + domainLogs[domain][key] = fullPath; + } + break; + } } }); - return Object.entries(domainLogs).map(([domain, logs]) => ({ + const result = Object.entries(domainLogs).map(([domain, logs]) => ({ domain, accessLog: logs.accessLog || '', errorLog: logs.errorLog || '', sslAccessLog: logs.sslAccessLog || '', - sslErrorLog: logs.sslErrorLog || '' + sslErrorLog: logs.sslErrorLog || '', })); + + logger.info(`Found ${result.length} domains with logs: ${result.map(d => d.domain).join(', ')}`); + + return result; } catch (error) { logger.error('Error reading domain log files:', error); return []; @@ -91,204 +245,277 @@ async function getDomainLogFiles(): Promise<{ domain: string; accessLog: string; } /** - * Get parsed logs from all sources + * Find existing log file from possible paths */ -export async function getParsedLogs(options: LogFilterOptions = {}): Promise { - const { limit = 100, level, type, search, domain } = options; +async function findExistingFile(paths: string[]): Promise { + for (const filePath of paths) { + if (!isPathSafe(filePath)) continue; + try { + await fs.access(filePath); + return filePath; + } catch { + continue; + } + } + return null; +} - const allLogs: ParsedLogEntry[] = []; +/** + * Get domain-specific log file paths + */ +function getDomainLogPaths(domain: string) { + return { + httpAccess: [ + path.join(LOG_PATHS.nginxDir, `${domain}_access.log`), + path.join(LOG_PATHS.nginxDir, `${domain}-access.log`) + ], + httpError: [ + path.join(LOG_PATHS.nginxDir, `${domain}_error.log`), + path.join(LOG_PATHS.nginxDir, `${domain}-error.log`) + ], + httpsAccess: [ + path.join(LOG_PATHS.nginxDir, `${domain}_ssl_access.log`), + path.join(LOG_PATHS.nginxDir, `${domain}-ssl-access.log`) + ], + httpsError: [ + path.join(LOG_PATHS.nginxDir, `${domain}_ssl_error.log`), + path.join(LOG_PATHS.nginxDir, `${domain}-ssl-error.log`) + ] + }; +} - try { - // If specific domain is requested, read only that domain's logs - if (domain && domain !== 'all') { - // Define all possible log file paths (both HTTP and HTTPS) - const logPaths = { - httpAccess: [ - path.join(NGINX_LOG_DIR, `${domain}_access.log`), - path.join(NGINX_LOG_DIR, `${domain}-access.log`) - ], - httpError: [ - path.join(NGINX_LOG_DIR, `${domain}_error.log`), - path.join(NGINX_LOG_DIR, `${domain}-error.log`) - ], - httpsAccess: [ - path.join(NGINX_LOG_DIR, `${domain}_ssl_access.log`), - path.join(NGINX_LOG_DIR, `${domain}-ssl-access.log`) - ], - httpsError: [ - path.join(NGINX_LOG_DIR, `${domain}_ssl_error.log`), - path.join(NGINX_LOG_DIR, `${domain}-ssl-error.log`) - ] - }; - - // Helper function to find existing log file - const findExistingFile = async (paths: string[]): Promise => { - for (const filePath of paths) { - try { - await fs.access(filePath); - return filePath; - } catch { - continue; - } - } - return null; - }; - - // Read domain access logs (both HTTP and HTTPS) - if (!type || type === 'all' || type === 'access') { - // HTTP access logs - const httpAccessLog = await findExistingFile(logPaths.httpAccess); - if (httpAccessLog) { - const accessLines = await readLastLines(httpAccessLog, Math.ceil(limit / 4)); - accessLines.forEach((line, index) => { - const parsed = parseAccessLogLine(line, index, domain); - if (parsed) allLogs.push(parsed); - }); - } +/** + * Read and parse log file + */ +async function readAndParseLog( + filePath: string | null, + limit: number, + parser: ParseOptions +): Promise { + if (!filePath) return []; + const lines = await readLastLines(filePath, limit); + return parseLogLines(lines, parser); +} - // HTTPS access logs - const httpsAccessLog = await findExistingFile(logPaths.httpsAccess); - if (httpsAccessLog) { - const sslAccessLines = await readLastLines(httpsAccessLog, Math.ceil(limit / 4)); - sslAccessLines.forEach((line, index) => { - const parsed = parseAccessLogLine(line, index, domain); - if (parsed) allLogs.push(parsed); - }); - } - } +/** + * Read domain-specific logs + */ +async function readDomainLogs( + domain: string, + limit: number, + type?: string +): Promise { + const logPaths = getDomainLogPaths(domain); + const results: ParsedLogEntry[] = []; + + const shouldReadAccess = !type || type === 'all' || type === 'access'; + const shouldReadError = !type || type === 'all' || type === 'error'; + + // Read access logs + if (shouldReadAccess) { + const [httpAccess, httpsAccess] = await Promise.all([ + findExistingFile(logPaths.httpAccess), + findExistingFile(logPaths.httpsAccess) + ]); + + const [httpLogs, httpsLogs] = await Promise.all([ + readAndParseLog(httpAccess, limit, { parser: 'access', domain }), + readAndParseLog(httpsAccess, limit, { parser: 'access', domain }) + ]); + + results.push(...httpLogs, ...httpsLogs); + } - // Read domain error logs (both HTTP and HTTPS) - if (!type || type === 'all' || type === 'error') { - // HTTP error logs - const httpErrorLog = await findExistingFile(logPaths.httpError); - if (httpErrorLog) { - const errorLines = await readLastLines(httpErrorLog, Math.ceil(limit / 4)); - errorLines.forEach((line, index) => { - const parsed = parseErrorLogLine(line, index); - if (parsed) { - parsed.domain = domain; - allLogs.push(parsed); - } - }); - } + // Read error logs + if (shouldReadError) { + const [httpError, httpsError] = await Promise.all([ + findExistingFile(logPaths.httpError), + findExistingFile(logPaths.httpsError) + ]); - // HTTPS error logs - const httpsErrorLog = await findExistingFile(logPaths.httpsError); - if (httpsErrorLog) { - const sslErrorLines = await readLastLines(httpsErrorLog, Math.ceil(limit / 4)); - sslErrorLines.forEach((line, index) => { - const parsed = parseErrorLogLine(line, index); - if (parsed) { - parsed.domain = domain; - allLogs.push(parsed); - } - }); - } - } - } else { - // Read global nginx logs - if (!type || type === 'all' || type === 'access') { - const accessLines = await readLastLines(NGINX_ACCESS_LOG, Math.ceil(limit / 3)); - accessLines.forEach((line, index) => { - const parsed = parseAccessLogLine(line, index); - if (parsed) allLogs.push(parsed); - }); - } + const [httpLogs, httpsLogs] = await Promise.all([ + readAndParseLog(httpError, limit, { parser: 'error', domain }), + readAndParseLog(httpsError, limit, { parser: 'error', domain }) + ]); - if (!type || type === 'all' || type === 'error') { - const errorLines = await readLastLines(NGINX_ERROR_LOG, Math.ceil(limit / 3)); - errorLines.forEach((line, index) => { - const parsed = parseErrorLogLine(line, index); - if (parsed) allLogs.push(parsed); - }); - } + results.push(...httpLogs, ...httpsLogs); + } - // Read ModSecurity logs - if (!type || type === 'all' || type === 'error') { - const modsecLines = await readLastLines(MODSEC_AUDIT_LOG, Math.ceil(limit / 3)); - modsecLines.forEach((line, index) => { - const parsed = parseModSecLogLine(line, index); - if (parsed) allLogs.push(parsed); - }); - } + return results; +} - // Also read all domain-specific logs if no specific domain requested - if (!domain || domain === 'all') { - const domainLogFiles = await getDomainLogFiles(); - const logsPerDomain = Math.ceil(limit / (domainLogFiles.length * 2 + 1)); // Divide among all domains and log types - - for (const { domain: domainName, accessLog, errorLog, sslAccessLog, sslErrorLog } of domainLogFiles) { - // HTTP access logs - if (accessLog && (!type || type === 'all' || type === 'access')) { - const lines = await readLastLines(accessLog, logsPerDomain); - lines.forEach((line, index) => { - const parsed = parseAccessLogLine(line, index, domainName); - if (parsed) allLogs.push(parsed); - }); - } +/** + * Read global logs + */ +async function readGlobalLogs( + limit: number, + type?: string +): Promise { + const results: ParsedLogEntry[] = []; + const logsPerType = Math.ceil(limit / 3); + + const shouldReadAccess = !type || type === 'all' || type === 'access'; + const shouldReadError = !type || type === 'all' || type === 'error'; + + if (shouldReadAccess && isPathSafe(LOG_PATHS.nginxAccess)) { + const logs = await readAndParseLog(LOG_PATHS.nginxAccess, logsPerType, { parser: 'access' }); + results.push(...logs); + } - // HTTPS access logs - if (sslAccessLog && (!type || type === 'all' || type === 'access')) { - const lines = await readLastLines(sslAccessLog, logsPerDomain); - lines.forEach((line, index) => { - const parsed = parseAccessLogLine(line, index, domainName); - if (parsed) allLogs.push(parsed); - }); - } + if (shouldReadError) { + const [errorLogs, modsecLogs] = await Promise.all([ + isPathSafe(LOG_PATHS.nginxError) + ? readAndParseLog(LOG_PATHS.nginxError, logsPerType, { parser: 'error' }) + : Promise.resolve([]), + isPathSafe(LOG_PATHS.modsecAudit) + ? readAndParseLog(LOG_PATHS.modsecAudit, logsPerType, { parser: 'modsec' }) + : Promise.resolve([]) + ]); + + results.push(...errorLogs, ...modsecLogs); + } + + return results; +} - // HTTP error logs - if (errorLog && (!type || type === 'all' || type === 'error')) { - const lines = await readLastLines(errorLog, logsPerDomain); - lines.forEach((line, index) => { - const parsed = parseErrorLogLine(line, index); - if (parsed) { - parsed.domain = domainName; - allLogs.push(parsed); - } - }); +/** + * Read all domain logs with concurrency control + */ +async function readAllDomainLogs( + limit: number, + type?: string +): Promise { + const results: ParsedLogEntry[] = []; + const domainLogFiles = await getDomainLogFiles(); + const logsPerDomain = Math.max(1, Math.ceil(limit / (domainLogFiles.length * 2 + 1))); + + for (let i = 0; i < domainLogFiles.length; i += SECURITY_LIMITS.maxConcurrentFiles) { + const batch = domainLogFiles.slice(i, i + SECURITY_LIMITS.maxConcurrentFiles); + + const batchResults = await Promise.all( + batch.map(async ({ domain, accessLog, errorLog, sslAccessLog, sslErrorLog }) => { + const domainResults: ParsedLogEntry[] = []; + const shouldReadAccess = !type || type === 'all' || type === 'access'; + const shouldReadError = !type || type === 'all' || type === 'error'; + + const readPromises: Promise[] = []; + + if (shouldReadAccess) { + if (accessLog) { + readPromises.push(readAndParseLog(accessLog, logsPerDomain, { parser: 'access', domain })); } + if (sslAccessLog) { + readPromises.push(readAndParseLog(sslAccessLog, logsPerDomain, { parser: 'access', domain })); + } + } - // HTTPS error logs - if (sslErrorLog && (!type || type === 'all' || type === 'error')) { - const lines = await readLastLines(sslErrorLog, logsPerDomain); - lines.forEach((line, index) => { - const parsed = parseErrorLogLine(line, index); - if (parsed) { - parsed.domain = domainName; - allLogs.push(parsed); - } - }); + if (shouldReadError) { + if (errorLog) { + readPromises.push(readAndParseLog(errorLog, logsPerDomain, { parser: 'error', domain })); + } + if (sslErrorLog) { + readPromises.push(readAndParseLog(sslErrorLog, logsPerDomain, { parser: 'error', domain })); } } - } - } - // Sort by timestamp descending (newest first) - allLogs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const results = await Promise.all(readPromises); + return results.flat(); + }) + ); - // Apply filters - let filtered = allLogs; + results.push(...batchResults.flat()); + } - if (level && level !== 'all') { - filtered = filtered.filter(log => log.level === level); - } + return results; +} + +/** + * Apply filters to log entries + */ +function applyFilters( + logs: ParsedLogEntry[], + filters: LogFilterOptions +): ParsedLogEntry[] { + let filtered = logs; + + if (filters.level && filters.level !== 'all') { + filtered = filtered.filter(log => log.level === filters.level); + } - if (type && type !== 'all') { - filtered = filtered.filter(log => log.type === type); + if (filters.type && filters.type !== 'all') { + filtered = filtered.filter(log => log.type === filters.type); + } + + if (filters.search) { + const searchTerm = filters.search + .toLowerCase() + .substring(0, SECURITY_LIMITS.maxSearchTermLength); + + filtered = filtered.filter(log => + log.message.toLowerCase().includes(searchTerm) || + log.source.toLowerCase().includes(searchTerm) || + (log.ip && log.ip.includes(searchTerm)) || + (log.path && log.path.toLowerCase().includes(searchTerm)) + ); + } + + if (filters.ruleId) { + const safeRuleId = filters.ruleId.substring(0, SECURITY_LIMITS.maxRuleIdLength); + filtered = filtered.filter(log => log.ruleId?.includes(safeRuleId)); + } + + if (filters.uniqueId) { + const safeUniqueId = filters.uniqueId.substring(0, SECURITY_LIMITS.maxUniqueIdLength); + filtered = filtered.filter(log => log.uniqueId?.includes(safeUniqueId)); + } + + return filtered; +} + +/** + * Get parsed logs from all sources + */ +export async function getParsedLogs(options: LogFilterOptions = {}): Promise { + const { + limit = 100, + offset = 0, + domain, + uniqueId, + type + } = options; + + const safeLimit = Math.min(Math.max(limit, 1), SECURITY_LIMITS.maxLinesPerFile); + const safeOffset = Math.max(offset || 0, 0); + + try { + // Validate domain + const safeDomain = domain ? sanitizeDomain(domain) : null; + if (domain && domain !== 'all' && !safeDomain) { + logger.warn(`Invalid domain: ${domain}`); + return []; } - if (search) { - const searchLower = search.toLowerCase(); - filtered = filtered.filter(log => - log.message.toLowerCase().includes(searchLower) || - log.source.toLowerCase().includes(searchLower) || - (log.ip && log.ip.includes(searchLower)) || - (log.path && log.path.toLowerCase().includes(searchLower)) - ); + let allLogs: ParsedLogEntry[]; + + // Read logs based on domain filter + if (safeDomain && safeDomain !== 'all') { + allLogs = await readDomainLogs(safeDomain, safeLimit, type); + } else { + const [globalLogs, domainLogs] = await Promise.all([ + readGlobalLogs(safeLimit, type), + domain === 'all' ? readAllDomainLogs(safeLimit, type) : Promise.resolve([]) + ]); + allLogs = [...globalLogs, ...domainLogs]; } - // Apply limit - return filtered.slice(0, limit); + // Sort by timestamp descending + allLogs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + // Apply filters + const filtered = applyFilters(allLogs, options); + + // Apply pagination + return filtered.slice(safeOffset, safeOffset + safeLimit); } catch (error) { logger.error('Error getting parsed logs:', error); return []; @@ -319,13 +546,13 @@ export async function getLogStats(): Promise { * Get available domains from database */ export async function getAvailableDomainsFromDb() { - return await prisma.domain.findMany({ - select: { - name: true, - status: true, - }, - orderBy: { - name: 'asc', - }, - }); -} + try { + return await prisma.domain.findMany({ + select: { name: true, status: true }, + orderBy: { name: 'asc' }, + }); + } catch (error) { + logger.error('Error fetching domains from database:', error); + return []; + } +} \ No newline at end of file diff --git a/apps/api/src/domains/logs/logs.types.ts b/apps/api/src/domains/logs/logs.types.ts index 3b29cb6..c8ae9ee 100644 --- a/apps/api/src/domains/logs/logs.types.ts +++ b/apps/api/src/domains/logs/logs.types.ts @@ -11,6 +11,7 @@ export interface ParsedLogEntry { message: string; domain?: string; ip?: string; + hostname?: string; // Target hostname/domain from ModSecurity logs method?: string; path?: string; statusCode?: number; @@ -29,10 +30,13 @@ export interface ParsedLogEntry { export interface LogFilterOptions { limit?: number; + offset?: number; level?: string; type?: string; search?: string; domain?: string; + ruleId?: string; + uniqueId?: string; } export interface LogStatistics { diff --git a/apps/api/src/domains/logs/services/log-parser.service.ts b/apps/api/src/domains/logs/services/log-parser.service.ts index 7399a08..434a729 100644 --- a/apps/api/src/domains/logs/services/log-parser.service.ts +++ b/apps/api/src/domains/logs/services/log-parser.service.ts @@ -123,21 +123,29 @@ export function parseModSecLogLine(line: string, index: number): ParsedLogEntry // ModSecurity logs are complex, extract key info if (!line.includes('ModSecurity:')) return null; - // Extract timestamp if present + // Extract timestamp - supports both nginx error log and ModSec audit log formats let timestamp = new Date().toISOString(); - const timeMatch = line.match(/\[(\d{2}\/\w{3}\/\d{4}:\d{2}:\d{2}:\d{2})/); - if (timeMatch) { - const [, timeStr] = timeMatch; - // Parse: 29/Mar/2025:14:35:22 - const timeParts = timeStr.match(/(\d+)\/(\w+)\/(\d+):(\d+):(\d+):(\d+)/); - if (timeParts) { - const [, day, monthStr, year, hour, min, sec] = timeParts; - const months: { [key: string]: string } = { - Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06', - Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12' - }; - const month = months[monthStr] || '01'; - timestamp = `${year}-${month}-${day.padStart(2, '0')}T${hour}:${min}:${sec}Z`; + + // Try nginx error log format first: 2025/10/24 06:22:01 + const nginxTimeMatch = line.match(/^(\d{4})\/(\d{2})\/(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/); + if (nginxTimeMatch) { + const [, year, month, day, hour, min, sec] = nginxTimeMatch; + timestamp = `${year}-${month}-${day}T${hour}:${min}:${sec}Z`; + } else { + // Try ModSec audit log format: [29/Mar/2025:14:35:22] + const auditTimeMatch = line.match(/\[(\d{2}\/\w{3}\/\d{4}:\d{2}:\d{2}:\d{2})/); + if (auditTimeMatch) { + const [, timeStr] = auditTimeMatch; + const timeParts = timeStr.match(/(\d+)\/(\w+)\/(\d+):(\d+):(\d+):(\d+)/); + if (timeParts) { + const [, day, monthStr, year, hour, min, sec] = timeParts; + const months: { [key: string]: string } = { + Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06', + Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12' + }; + const month = months[monthStr] || '01'; + timestamp = `${year}-${month}-${day.padStart(2, '0')}T${hour}:${min}:${sec}Z`; + } } } @@ -160,10 +168,13 @@ export function parseModSecLogLine(line: string, index: number): ParsedLogEntry tags.push(match[1]); } - // Extract IP - from [client 52.186.182.85] or [hostname "10.0.0.203"] - const clientIpMatch = line.match(/\[client ([\d.]+)\]/); + // Extract client IP - from [client 52.186.182.85] + const clientIpMatch = line.match(/\[client ([\d.]+)(?::\d+)?\]/); + const ip = clientIpMatch ? clientIpMatch[1] : undefined; + + // Extract hostname/domain separately - from [hostname "domain.com"] const hostnameMatch = line.match(/\[hostname "([^"]+)"\]/); - const ip = clientIpMatch ? clientIpMatch[1] : (hostnameMatch ? hostnameMatch[1] : undefined); + const hostname = hostnameMatch ? hostnameMatch[1] : undefined; // Extract URI - [uri "/device.rsp"] const uriMatch = line.match(/\[uri "([^"]+)"\]/); @@ -214,6 +225,8 @@ export function parseModSecLogLine(line: string, index: number): ParsedLogEntry message: `ModSecurity: ${message}`, fullMessage, // Complete log without truncation ip, + domain: hostname, // Use domain field for consistency with nginx logs + hostname, // Keep hostname for backward compatibility method, path, statusCode, diff --git a/apps/api/src/domains/modsec/modsec.controller.ts b/apps/api/src/domains/modsec/modsec.controller.ts index b330321..4e3ebd0 100644 --- a/apps/api/src/domains/modsec/modsec.controller.ts +++ b/apps/api/src/domains/modsec/modsec.controller.ts @@ -200,10 +200,21 @@ export class ModSecController { return; } + // Handle validation errors (rule ID duplicates, nginx config errors) + if (error.message.includes('Rule ID(s) already exist') || + error.message.includes('Nginx configuration test failed') || + error.message.includes('Nginx reload failed')) { + res.status(400).json({ + success: false, + message: error.message, + }); + return; + } + logger.error('Add custom rule error:', error); res.status(500).json({ success: false, - message: 'Internal server error', + message: error.message || 'Internal server error', }); } } @@ -254,10 +265,21 @@ export class ModSecController { return; } + // Handle validation errors (rule ID duplicates, nginx config errors) + if (error.message.includes('Rule ID(s) already exist') || + error.message.includes('Nginx configuration test failed') || + error.message.includes('Nginx reload failed')) { + res.status(400).json({ + success: false, + message: error.message, + }); + return; + } + logger.error('Update ModSec rule error:', error); res.status(500).json({ success: false, - message: 'Internal server error', + message: error.message || 'Internal server error', }); } } @@ -354,6 +376,39 @@ export class ModSecController { }); } } + + /** + * Reinitialize ModSecurity configuration + * This will update main.conf with any missing includes + */ + async reinitializeConfig(req: AuthRequest, res: Response): Promise { + try { + const result = await modSecService.reinitializeConfig(); + + logger.info('ModSecurity configuration reinitialized', { + userId: req.user?.userId, + success: result.success, + }); + + if (result.success) { + res.json({ + success: true, + message: result.message, + }); + } else { + res.status(500).json({ + success: false, + message: result.message, + }); + } + } catch (error) { + logger.error('Reinitialize ModSec config error:', error); + res.status(500).json({ + success: false, + message: 'Internal server error', + }); + } + } } export const modSecController = new ModSecController(); diff --git a/apps/api/src/domains/modsec/modsec.routes.ts b/apps/api/src/domains/modsec/modsec.routes.ts index 7c55656..99c2e96 100644 --- a/apps/api/src/domains/modsec/modsec.routes.ts +++ b/apps/api/src/domains/modsec/modsec.routes.ts @@ -70,4 +70,9 @@ router.post( (req: AuthRequest, res: Response) => modSecController.setGlobalModSec(req, res) ); +// Reinitialize ModSecurity configuration +router.post('/reinitialize', authorize('admin'), (req: AuthRequest, res: Response) => + modSecController.reinitializeConfig(req, res) +); + export default router; diff --git a/apps/api/src/domains/modsec/modsec.service.ts b/apps/api/src/domains/modsec/modsec.service.ts index ec2e4f4..54fd6ed 100644 --- a/apps/api/src/domains/modsec/modsec.service.ts +++ b/apps/api/src/domains/modsec/modsec.service.ts @@ -5,6 +5,7 @@ import { promisify } from 'util'; import logger from '../../utils/logger'; import { modSecRepository } from './modsec.repository'; import { crsRulesService } from './services/crs-rules.service'; +import { modSecSetupService } from './services/modsec-setup.service'; import { AddCustomRuleDto, UpdateModSecRuleDto, ToggleCRSRuleDto, SetGlobalModSecDto } from './dto'; import { CRSRule, ModSecRule, ModSecRuleWithDomain, GlobalModSecSettings, NginxReloadResult } from './modsec.types'; @@ -41,6 +42,86 @@ export class ModSecService { } } + /** + * Extract rule IDs from custom rule content + */ + private extractRuleIdsFromContent(ruleContent: string): number[] { + const idMatches = ruleContent.matchAll(/id:(\d+)/g); + const ids = new Set(); + + for (const match of idMatches) { + ids.add(parseInt(match[1])); + } + + return Array.from(ids).sort((a, b) => a - b); + } + + /** + * Get all existing rule IDs from custom rules + */ + private async getAllExistingRuleIds(excludeRuleId?: string): Promise> { + const allRuleIds = new Set(); + + try { + // Get all custom rules from database + const customRules = await modSecRepository.findModSecRules(); + + for (const rule of customRules) { + // Skip the rule being updated + if (excludeRuleId && rule.id === excludeRuleId) { + continue; + } + + // Extract IDs from rule content + const ruleIds = this.extractRuleIdsFromContent(rule.ruleContent); + ruleIds.forEach(id => allRuleIds.add(id)); + } + } catch (error: any) { + logger.error('Failed to get existing rule IDs:', error); + } + + return allRuleIds; + } + + /** + * Validate rule IDs for duplicates + */ + private async validateRuleIds(ruleContent: string, excludeRuleId?: string): Promise<{ valid: boolean; duplicateIds: number[] }> { + const newRuleIds = this.extractRuleIdsFromContent(ruleContent); + + if (newRuleIds.length === 0) { + return { valid: true, duplicateIds: [] }; + } + + const existingRuleIds = await this.getAllExistingRuleIds(excludeRuleId); + const duplicateIds: number[] = []; + + for (const id of newRuleIds) { + if (existingRuleIds.has(id)) { + duplicateIds.push(id); + } + } + + return { + valid: duplicateIds.length === 0, + duplicateIds, + }; + } + + /** + * Test nginx configuration validity + */ + private async testNginxConfig(): Promise<{ valid: boolean; error?: string }> { + try { + await execAsync('nginx -t 2>&1'); + return { valid: true }; + } catch (error: any) { + const errorMessage = error.stderr || error.stdout || error.message; + logger.error('Nginx configuration test failed:', errorMessage); + return { valid: false, error: errorMessage }; + } + } + /** * Regenerate CRS disable configuration file from database */ @@ -249,13 +330,46 @@ export class ModSecService { const updatedRule = await modSecRepository.toggleModSecRule(id, !rule.enabled); + // Handle file renaming based on enabled status + const enabledFileName = `custom_${rule.id}.conf`; + const disabledFileName = `custom_${rule.id}.conf.disabled`; + const enabledFilePath = path.join(MODSEC_CUSTOM_RULES_PATH, enabledFileName); + const disabledFilePath = path.join(MODSEC_CUSTOM_RULES_PATH, disabledFileName); + + try { + if (updatedRule.enabled) { + // Enable: rename .disabled to .conf + try { + await fs.access(disabledFilePath); + await fs.rename(disabledFilePath, enabledFilePath); + logger.info(`Renamed rule file to enabled: ${enabledFileName}`); + } catch (error) { + // File might not exist or already enabled, that's ok + logger.warn(`Could not find disabled file to enable: ${disabledFileName}`); + } + } else { + // Disable: rename .conf to .conf.disabled + try { + await fs.access(enabledFilePath); + await fs.rename(enabledFilePath, disabledFilePath); + logger.info(`Renamed rule file to disabled: ${disabledFileName}`); + } catch (error) { + // File might not exist or already disabled, that's ok + logger.warn(`Could not find enabled file to disable: ${enabledFileName}`); + } + } + + // Auto reload nginx + await this.autoReloadNginx(true); + } catch (error: any) { + logger.error('Failed to rename rule file:', error); + // Continue even if file rename fails + } + logger.info(`ModSecurity rule ${updatedRule.name} ${updatedRule.enabled ? 'enabled' : 'disabled'}`, { ruleId: id, }); - // Auto reload nginx - await this.autoReloadNginx(true); - return updatedRule; } @@ -268,27 +382,72 @@ export class ModSecService { } } - // Create rule in database - const rule = await modSecRepository.createModSecRule(dto); - - // Write rule to file if enabled - if (rule.enabled) { - try { - // Ensure custom rules directory exists - await fs.mkdir(MODSEC_CUSTOM_RULES_PATH, { recursive: true }); + // Validate rule IDs for duplicates + const ruleIdValidation = await this.validateRuleIds(dto.ruleContent); + if (!ruleIdValidation.valid) { + throw new Error(`Rule ID(s) already exist: ${ruleIdValidation.duplicateIds.join(', ')}. Please use unique rule IDs.`); + } - const ruleFileName = `custom_${rule.id}.conf`; - const ruleFilePath = path.join(MODSEC_CUSTOM_RULES_PATH, ruleFileName); + // Create rule in database first + const rule = await modSecRepository.createModSecRule(dto); - await fs.writeFile(ruleFilePath, dto.ruleContent, 'utf-8'); - logger.info(`Custom ModSecurity rule file created: ${ruleFilePath}`); + // Write rule to file (with appropriate extension based on enabled status) + let ruleFilePath: string | null = null; + try { + // Ensure custom rules directory exists + await fs.mkdir(MODSEC_CUSTOM_RULES_PATH, { recursive: true }); + + const ruleFileName = rule.enabled + ? `custom_${rule.id}.conf` + : `custom_${rule.id}.conf.disabled`; + ruleFilePath = path.join(MODSEC_CUSTOM_RULES_PATH, ruleFileName); + + await fs.writeFile(ruleFilePath, dto.ruleContent, 'utf-8'); + logger.info(`Custom ModSecurity rule file created: ${ruleFilePath}`); + + // Test nginx configuration if rule is enabled + if (rule.enabled) { + const configTest = await this.testNginxConfig(); + if (!configTest.valid) { + // Rollback: delete the file and database entry + try { + await fs.unlink(ruleFilePath); + await modSecRepository.deleteModSecRule(rule.id); + } catch (rollbackError) { + logger.error('Failed to rollback after nginx config test failure:', rollbackError); + } + throw new Error(`Nginx configuration test failed: ${configTest.error}`); + } - // Auto reload nginx - await this.autoReloadNginx(true); - } catch (error: any) { - logger.error('Failed to write custom rule file:', error); - // Continue even if file write fails + // Reload nginx if config is valid + const reloadResult = await this.autoReloadNginx(false); + if (!reloadResult.success) { + // Rollback: delete the file and database entry + try { + await fs.unlink(ruleFilePath); + await modSecRepository.deleteModSecRule(rule.id); + } catch (rollbackError) { + logger.error('Failed to rollback after nginx reload failure:', rollbackError); + } + throw new Error(`Nginx reload failed: ${reloadResult.message}`); + } + } + } catch (error: any) { + logger.error('Failed to write custom rule file:', error); + // If it's our validation error, rethrow it + if (error.message.includes('Nginx configuration test failed') || error.message.includes('Nginx reload failed')) { + throw error; + } + // For other errors, try to clean up + if (ruleFilePath) { + try { + await fs.unlink(ruleFilePath); + await modSecRepository.deleteModSecRule(rule.id); + } catch (cleanupError) { + logger.error('Failed to cleanup after error:', cleanupError); + } } + throw new Error(`Failed to create custom rule: ${error.message}`); } logger.info(`Custom ModSecurity rule added: ${rule.name}`, { @@ -304,32 +463,147 @@ export class ModSecService { throw new Error('ModSecurity rule not found'); } + // Validate rule IDs for duplicates if content is being updated + if (dto.ruleContent !== undefined) { + const ruleIdValidation = await this.validateRuleIds(dto.ruleContent, id); + if (!ruleIdValidation.valid) { + throw new Error(`Rule ID(s) already exist: ${ruleIdValidation.duplicateIds.join(', ')}. Please use unique rule IDs.`); + } + } + + // Store original state for rollback + const originalRule = { ...rule }; const updatedRule = await modSecRepository.updateModSecRule(id, dto); - // Update rule file if exists - const ruleFileName = `custom_${rule.id}.conf`; - const ruleFilePath = path.join(MODSEC_CUSTOM_RULES_PATH, ruleFileName); + // Handle file updates with proper naming + const enabledFileName = `custom_${rule.id}.conf`; + const disabledFileName = `custom_${rule.id}.conf.disabled`; + const enabledFilePath = path.join(MODSEC_CUSTOM_RULES_PATH, enabledFileName); + const disabledFilePath = path.join(MODSEC_CUSTOM_RULES_PATH, disabledFileName); + + let backupFilePath: string | null = null; + let currentFilePath: string | null = null; try { - await fs.access(ruleFilePath); - - if (updatedRule.enabled && dto.ruleContent) { - await fs.writeFile(ruleFilePath, dto.ruleContent, 'utf-8'); - logger.info(`Custom ModSecurity rule file updated: ${ruleFilePath}`); - } else if (!updatedRule.enabled) { - await fs.unlink(ruleFilePath); - logger.info(`Custom ModSecurity rule file removed: ${ruleFilePath}`); + await fs.mkdir(MODSEC_CUSTOM_RULES_PATH, { recursive: true }); + + // Determine which file currently exists + try { + await fs.access(enabledFilePath); + currentFilePath = enabledFilePath; + } catch { + try { + await fs.access(disabledFilePath); + currentFilePath = disabledFilePath; + } catch { + currentFilePath = null; + } } - // Auto reload nginx - await this.autoReloadNginx(true); + // Create backup if file exists + if (currentFilePath) { + backupFilePath = `${currentFilePath}.backup`; + await fs.copyFile(currentFilePath, backupFilePath); + } + + // Update content if provided + if (dto.ruleContent !== undefined) { + const targetFilePath = updatedRule.enabled ? enabledFilePath : disabledFilePath; + + // Write new content to target file + await fs.writeFile(targetFilePath, dto.ruleContent, 'utf-8'); + logger.info(`Custom ModSecurity rule file updated: ${targetFilePath}`); + + // Remove old file if it has different name + if (currentFilePath && currentFilePath !== targetFilePath) { + try { + await fs.unlink(currentFilePath); + logger.info(`Removed old rule file: ${currentFilePath}`); + } catch (error) { + logger.warn(`Could not remove old file: ${currentFilePath}`); + } + } + } else if (dto.enabled !== undefined && currentFilePath) { + // Only enabled status changed, rename file + const targetFilePath = updatedRule.enabled ? enabledFilePath : disabledFilePath; + if (currentFilePath !== targetFilePath) { + await fs.rename(currentFilePath, targetFilePath); + logger.info(`Renamed rule file from ${currentFilePath} to ${targetFilePath}`); + } + } + + // Test nginx configuration if rule is enabled + if (updatedRule.enabled) { + const configTest = await this.testNginxConfig(); + if (!configTest.valid) { + // Rollback: restore backup and database entry + if (backupFilePath && currentFilePath) { + try { + await fs.copyFile(backupFilePath, currentFilePath); + await modSecRepository.updateModSecRule(id, { + name: originalRule.name, + category: originalRule.category, + ruleContent: originalRule.ruleContent, + description: originalRule.description, + enabled: originalRule.enabled, + }); + } catch (rollbackError) { + logger.error('Failed to rollback after nginx config test failure:', rollbackError); + } + } + throw new Error(`Nginx configuration test failed: ${configTest.error}`); + } + + // Reload nginx if config is valid + const reloadResult = await this.autoReloadNginx(false); + if (!reloadResult.success) { + // Rollback: restore backup and database entry + if (backupFilePath && currentFilePath) { + try { + await fs.copyFile(backupFilePath, currentFilePath); + await modSecRepository.updateModSecRule(id, { + name: originalRule.name, + category: originalRule.category, + ruleContent: originalRule.ruleContent, + description: originalRule.description, + enabled: originalRule.enabled, + }); + } catch (rollbackError) { + logger.error('Failed to rollback after nginx reload failure:', rollbackError); + } + } + throw new Error(`Nginx reload failed: ${reloadResult.message}`); + } + } + + // Clean up backup file + if (backupFilePath) { + try { + await fs.unlink(backupFilePath); + } catch (error) { + logger.warn(`Could not remove backup file: ${backupFilePath}`); + } + } } catch (error: any) { - // File doesn't exist or error accessing it - if (updatedRule.enabled && dto.ruleContent) { - await fs.mkdir(MODSEC_CUSTOM_RULES_PATH, { recursive: true }); - await fs.writeFile(ruleFilePath, dto.ruleContent, 'utf-8'); - await this.autoReloadNginx(true); + logger.error('Failed to update rule file:', error); + + // Clean up backup file on error + if (backupFilePath) { + try { + await fs.unlink(backupFilePath); + } catch (cleanupError) { + logger.warn(`Could not remove backup file: ${backupFilePath}`); + } + } + + // If it's our validation error, rethrow it + if (error.message.includes('Nginx configuration test failed') || + error.message.includes('Nginx reload failed') || + error.message.includes('Rule ID(s) already exist')) { + throw error; } + + throw new Error(`Failed to update custom rule: ${error.message}`); } logger.info(`ModSecurity rule updated: ${updatedRule.name}`, { @@ -347,18 +621,35 @@ export class ModSecService { await modSecRepository.deleteModSecRule(id); - // Delete rule file if exists - const ruleFileName = `custom_${rule.id}.conf`; - const ruleFilePath = path.join(MODSEC_CUSTOM_RULES_PATH, ruleFileName); + // Delete both enabled and disabled rule files if they exist + const enabledFileName = `custom_${rule.id}.conf`; + const disabledFileName = `custom_${rule.id}.conf.disabled`; + const enabledFilePath = path.join(MODSEC_CUSTOM_RULES_PATH, enabledFileName); + const disabledFilePath = path.join(MODSEC_CUSTOM_RULES_PATH, disabledFileName); + + let fileDeleted = false; + + // Try to delete enabled file + try { + await fs.unlink(enabledFilePath); + logger.info(`Custom ModSecurity rule file deleted: ${enabledFilePath}`); + fileDeleted = true; + } catch (error: any) { + // File doesn't exist, that's ok + } + // Try to delete disabled file try { - await fs.unlink(ruleFilePath); - logger.info(`Custom ModSecurity rule file deleted: ${ruleFilePath}`); + await fs.unlink(disabledFilePath); + logger.info(`Custom ModSecurity rule file deleted: ${disabledFilePath}`); + fileDeleted = true; + } catch (error: any) { + // File doesn't exist, that's ok + } - // Auto reload nginx + // Auto reload nginx if any file was deleted + if (fileDeleted) { await this.autoReloadNginx(true); - } catch (error: any) { - // File doesn't exist, continue } logger.info(`ModSecurity rule deleted: ${rule.name}`, { @@ -401,6 +692,21 @@ export class ModSecService { return config; } + + /** + * Reinitialize ModSecurity configuration + * This will update main.conf with any missing includes + */ + async reinitializeConfig(): Promise<{ success: boolean; message: string }> { + const result = await modSecSetupService.reinitializeModSecurityConfig(); + + if (result.success) { + // Auto reload nginx after config update + await this.autoReloadNginx(true); + } + + return result; + } } export const modSecService = new ModSecService(); diff --git a/apps/api/src/domains/modsec/services/modsec-setup.service.ts b/apps/api/src/domains/modsec/services/modsec-setup.service.ts index f52ca33..4de48bc 100644 --- a/apps/api/src/domains/modsec/services/modsec-setup.service.ts +++ b/apps/api/src/domains/modsec/services/modsec-setup.service.ts @@ -5,12 +5,33 @@ import logger from '../../../utils/logger'; const MODSEC_MAIN_CONF = '/etc/nginx/modsec/main.conf'; const MODSEC_CRS_DISABLE_PATH = '/etc/nginx/modsec/crs_disabled'; const MODSEC_CRS_DISABLE_FILE = '/etc/nginx/modsec/crs_disabled.conf'; +const MODSEC_CUSTOM_RULES_PATH = '/etc/nginx/modsec/custom_rules'; /** * ModSecurity setup service * Handles initialization and configuration of ModSecurity */ export class ModSecSetupService { + /** + * Force reinitialize ModSecurity configuration + * This will update main.conf with any missing includes + */ + async reinitializeModSecurityConfig(): Promise<{ success: boolean; message: string }> { + try { + await this.initializeModSecurityConfig(); + return { + success: true, + message: 'ModSecurity configuration reinitialized successfully', + }; + } catch (error: any) { + logger.error('Failed to reinitialize ModSecurity config:', error); + return { + success: false, + message: error.message || 'Failed to reinitialize ModSecurity configuration', + }; + } + } + /** * Initialize ModSecurity configuration for CRS rule management */ @@ -30,6 +51,32 @@ export class ModSecSetupService { logger.info(`✓ CRS disable directory already exists: ${MODSEC_CRS_DISABLE_PATH}`); } + // Step 2: Create custom_rules directory + try { + await fs.mkdir(MODSEC_CUSTOM_RULES_PATH, { recursive: true }); + await fs.chmod(MODSEC_CUSTOM_RULES_PATH, 0o755); + logger.info(`✓ Custom rules directory created: ${MODSEC_CUSTOM_RULES_PATH}`); + } catch (error: any) { + if (error.code !== 'EEXIST') { + throw error; + } + logger.info(`✓ Custom rules directory already exists: ${MODSEC_CUSTOM_RULES_PATH}`); + } + + // Create placeholder file to prevent nginx error when no custom rules exist + const placeholderFile = path.join(MODSEC_CUSTOM_RULES_PATH, 'placeholder.conf'); + try { + await fs.access(placeholderFile); + logger.info('✓ Custom rules placeholder file already exists'); + } catch (error) { + const placeholderContent = `# Custom ModSecurity Rules Placeholder +# This file ensures nginx doesn't fail when no custom rules exist +# Managed by Nginx Love UI - DO NOT EDIT MANUALLY +`; + await fs.writeFile(placeholderFile, placeholderContent, 'utf-8'); + logger.info('✓ Created custom rules placeholder file'); + } + // Step 3: Check if main.conf exists try { await fs.access(MODSEC_MAIN_CONF); @@ -98,15 +145,32 @@ export class ModSecSetupService { } // Check if crs_disabled.conf include exists + let needsUpdate = false; if (mainConfContent.includes('Include /etc/nginx/modsec/crs_disabled.conf')) { logger.info('✓ CRS disable include already configured in main.conf'); } else { // Add include directive for CRS disable file (single file, not wildcard) const includeDirective = `\n# CRS Rule Disables (managed by Nginx Love UI)\nInclude /etc/nginx/modsec/crs_disabled.conf\n`; mainConfContent += includeDirective; + needsUpdate = true; + logger.info('✓ Added CRS disable include to main.conf'); + } + + // Check if custom_rules include exists + if (mainConfContent.includes('Include /etc/nginx/modsec/custom_rules/*.conf')) { + logger.info('✓ Custom rules include already configured in main.conf'); + } else { + // Add include directive for custom rules + const customRulesDirective = `\n# Custom ModSecurity Rules (managed by Nginx Love UI)\nInclude /etc/nginx/modsec/custom_rules/*.conf\n`; + mainConfContent += customRulesDirective; + needsUpdate = true; + logger.info('✓ Added custom rules include to main.conf'); + } + // Write main.conf if updated + if (needsUpdate) { await fs.writeFile(MODSEC_MAIN_CONF, mainConfContent, 'utf-8'); - logger.info('✓ Added CRS disable include to main.conf'); + logger.info('✓ Updated main.conf with new includes'); } // Step 5: Create empty crs_disabled.conf if not exists diff --git a/apps/api/src/domains/nlb/dto/nlb.dto.ts b/apps/api/src/domains/nlb/dto/nlb.dto.ts index ad59d8f..4418834 100644 --- a/apps/api/src/domains/nlb/dto/nlb.dto.ts +++ b/apps/api/src/domains/nlb/dto/nlb.dto.ts @@ -4,6 +4,50 @@ import { body, param, query } from 'express-validator'; * Validation rules for NLB endpoints */ +/** + * Validate host (IP address or hostname) + */ +function isValidHost(host: string): boolean { + if (!host || host.trim().length === 0) { + return false; + } + + host = host.trim(); + + // IPv4 validation - strict format + const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + if (ipv4Regex.test(host)) { + return true; + } + + // IPv6 validation (simplified) + const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^[0-9a-fA-F]{1,4}::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}$/; + if (ipv6Regex.test(host)) { + return true; + } + + // If it looks like an IP but failed validation, reject it + // This catches malformed IPs like "888880.8832884" + if (/^[\d.]+$/.test(host)) { + return false; // Only digits and dots but not valid IP + } + + // Hostname validation (RFC 1123) + const hostnameRegex = /^(?=.{1,253}$)(?:(?!-)[A-Za-z0-9-]{1,63}(? /^\d+$/.test(label)); + if (allNumeric) { + return false; + } + return true; + } + + return false; +} + // Upstream validation export const upstreamValidation = [ body('host') @@ -11,7 +55,13 @@ export const upstreamValidation = [ .notEmpty() .withMessage('Host is required') .isString() - .withMessage('Host must be a string'), + .withMessage('Host must be a string') + .custom((value) => { + if (!isValidHost(value)) { + throw new Error('Invalid host. Must be a valid IP address (IPv4/IPv6) or hostname'); + } + return true; + }), body('port') .isInt({ min: 1, max: 65535 }) .withMessage('Port must be between 1 and 65535'), @@ -72,7 +122,13 @@ export const createNLBValidation = [ body('upstreams.*.host') .trim() .notEmpty() - .withMessage('Upstream host is required'), + .withMessage('Upstream host is required') + .custom((value) => { + if (!isValidHost(value)) { + throw new Error('Invalid host. Must be a valid IP address (IPv4/IPv6) or hostname'); + } + return true; + }), body('upstreams.*.port') .isInt({ min: 1, max: 65535 }) .withMessage('Upstream port must be between 1 and 65535'), @@ -188,7 +244,13 @@ export const updateNLBValidation = [ .optional() .trim() .notEmpty() - .withMessage('Upstream host is required'), + .withMessage('Upstream host is required') + .custom((value) => { + if (value && !isValidHost(value)) { + throw new Error('Invalid host. Must be a valid IP address (IPv4/IPv6) or hostname'); + } + return true; + }), body('upstreams.*.port') .optional() .isInt({ min: 1, max: 65535 }) diff --git a/apps/web/src/components/access-lists/AccessListFormDialog.tsx b/apps/web/src/components/access-lists/AccessListFormDialog.tsx index 576b059..02a1082 100644 --- a/apps/web/src/components/access-lists/AccessListFormDialog.tsx +++ b/apps/web/src/components/access-lists/AccessListFormDialog.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Plus, Trash2, Eye, EyeOff } from 'lucide-react'; +import { Plus, Trash2, Eye, EyeOff, AlertCircle, CheckCircle2, Info } from 'lucide-react'; import { Dialog, DialogContent, @@ -23,7 +23,16 @@ import { } from '@/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { useToast } from '@/hooks/use-toast'; +import { + validateAccessListName, + validateAccessListIp, + validateUsername, + validatePassword, + getAccessListHints, + getAccessListExample +} from '@/utils/access-list-validators'; import { useCreateAccessList, useUpdateAccessList, @@ -75,6 +84,20 @@ export function AccessListFormDialog({ const [selectedDomains, setSelectedDomains] = useState([]); const [originalDomainIds, setOriginalDomainIds] = useState([]); // Track original domains for edit mode + // Validation states + const [nameValidation, setNameValidation] = useState<{ valid: boolean; error?: string }>({ valid: true }); + const [ipValidations, setIpValidations] = useState>({}); + const [userValidations, setUserValidations] = useState>({}); + + // Validate name in real-time + useEffect(() => { + if (formData.name.trim().length > 0) { + setNameValidation(validateAccessListName(formData.name)); + } else { + setNameValidation({ valid: true }); + } + }, [formData.name]); + // Reset form when dialog opens or access list changes useEffect(() => { if (open) { @@ -120,6 +143,10 @@ export function AccessListFormDialog({ setSelectedDomains([]); setOriginalDomainIds([]); // Reset original domains } + // Reset validations + setNameValidation({ valid: true }); + setIpValidations({}); + setUserValidations({}); } }, [open, accessList]); @@ -280,6 +307,18 @@ export function AccessListFormDialog({ const newIps = [...allowedIps]; newIps[index] = value; setAllowedIps(newIps); + + // Validate IP in real-time + if (value.trim().length > 0) { + const validation = validateAccessListIp(value); + setIpValidations(prev => ({ ...prev, [index]: validation })); + } else { + setIpValidations(prev => { + const newValidations = { ...prev }; + delete newValidations[index]; + return newValidations; + }); + } }; const addAuthUser = () => { @@ -301,6 +340,31 @@ export function AccessListFormDialog({ const newUsers = [...authUsers]; (newUsers[index] as any)[field] = value; setAuthUsers(newUsers); + + // Validate username/password in real-time + if (field === 'username' && typeof value === 'string') { + if (value.trim().length > 0) { + const validation = validateUsername(value); + setUserValidations(prev => ({ + ...prev, + [index]: { + username: validation, + password: prev[index]?.password || { valid: true } + } + })); + } + } else if (field === 'password' && typeof value === 'string') { + if (value.trim().length > 0) { + const validation = validatePassword(value, !isEditMode); + setUserValidations(prev => ({ + ...prev, + [index]: { + username: prev[index]?.username || { valid: true }, + password: validation + } + })); + } + } }; const toggleDomainSelection = (domainId: string) => { @@ -338,16 +402,29 @@ export function AccessListFormDialog({
- - setFormData({ ...formData, name: e.target.value }) - } - placeholder="e.g., admin-panel-access" - disabled={isPending} - required - /> +
+ + setFormData({ ...formData, name: e.target.value }) + } + placeholder={getAccessListExample('name')} + disabled={isPending} + required + className={!nameValidation.valid && formData.name.trim().length > 0 ? 'border-red-500' : nameValidation.valid && formData.name.trim().length > 0 ? 'border-green-500' : ''} + /> + {nameValidation.valid && formData.name.trim().length > 0 && ( + + )} + {!nameValidation.valid && formData.name.trim().length > 0 && ( + + )} +
+ {!nameValidation.valid && nameValidation.error && ( +

{nameValidation.error}

+ )} +

{getAccessListHints('name')}

@@ -430,29 +507,46 @@ export function AccessListFormDialog({
{allowedIps.map((ip, index) => ( -
- updateIpField(index, e.target.value)} - placeholder="e.g., 192.168.1.1 or 10.0.0.0/24" - disabled={isPending} - /> - {allowedIps.length > 1 && ( - +
+
+
+ updateIpField(index, e.target.value)} + placeholder={getAccessListExample('ip')} + disabled={isPending} + className={ipValidations[index] && !ipValidations[index].valid ? 'border-red-500' : ipValidations[index]?.valid ? 'border-green-500' : ''} + /> + {ipValidations[index]?.valid && ip.trim().length > 0 && ( + + )} + {ipValidations[index] && !ipValidations[index].valid && ( + + )} +
+ {allowedIps.length > 1 && ( + + )} +
+ {ipValidations[index] && !ipValidations[index].valid && ipValidations[index].error && ( +

{ipValidations[index].error}

)}
))} -

- Enter IP addresses or CIDR notation (e.g., 192.168.1.0/24) -

+ + + + Hint: {getAccessListHints('ip')} + +
)} @@ -496,15 +590,27 @@ export function AccessListFormDialog({
- - updateAuthUser(index, 'username', e.target.value) - } - placeholder="username" - disabled={isPending} - minLength={3} - /> +
+ + updateAuthUser(index, 'username', e.target.value) + } + placeholder={getAccessListExample('username')} + disabled={isPending} + minLength={3} + className={userValidations[index]?.username && !userValidations[index].username.valid ? 'border-red-500' : userValidations[index]?.username?.valid ? 'border-green-500' : ''} + /> + {userValidations[index]?.username?.valid && user.username.trim().length > 0 && ( + + )} + {userValidations[index]?.username && !userValidations[index].username.valid && ( + + )} +
+ {userValidations[index]?.username && !userValidations[index].username.valid && userValidations[index].username.error && ( +

{userValidations[index].username.error}

+ )}
diff --git a/apps/web/src/components/acl/PreviewConfigDialog.tsx b/apps/web/src/components/acl/PreviewConfigDialog.tsx new file mode 100644 index 0000000..67a4b72 --- /dev/null +++ b/apps/web/src/components/acl/PreviewConfigDialog.tsx @@ -0,0 +1,104 @@ +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Loader2, FileCode, Copy, CheckCircle } from "lucide-react"; +import { usePreviewAclConfig } from "@/queries"; +import { useState } from "react"; +import { useToast } from "@/hooks/use-toast"; + +interface PreviewConfigDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function PreviewConfigDialog({ open, onOpenChange }: PreviewConfigDialogProps) { + const { toast } = useToast(); + const { data, isLoading, error } = usePreviewAclConfig(); + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + if (data?.config) { + navigator.clipboard.writeText(data.config); + setCopied(true); + toast({ + title: "Copied!", + description: "Configuration copied to clipboard" + }); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( + + + + + + Preview Nginx ACL Configuration + + + Review the generated nginx configuration before applying + + + +
+ {isLoading && ( +
+ +
+ )} + + {error && ( + + + Failed to load configuration preview. Please try again. + + + )} + + {data && ( + <> + + + {data.rulesCount} enabled rule{data.rulesCount !== 1 ? 's' : ''} will be applied to nginx configuration + + + +
+
+ +
+
+                  {data.config}
+                
+
+ + )} +
+ +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/domains/DomainDialog.tsx b/apps/web/src/components/domains/DomainDialog.tsx index c02bd23..682e301 100644 --- a/apps/web/src/components/domains/DomainDialog.tsx +++ b/apps/web/src/components/domains/DomainDialog.tsx @@ -181,7 +181,7 @@ export function DomainDialog({ open, onOpenChange, domain, onSave }: DomainDialo }; onSave(domainData); - onOpenChange(false); + // Do not close dialog here - let parent component handle it after successful save }; const addUpstream = () => { diff --git a/apps/web/src/components/domains/DomainDialogV2.tsx b/apps/web/src/components/domains/DomainDialogV2.tsx index 1215ca0..99d3e13 100644 --- a/apps/web/src/components/domains/DomainDialogV2.tsx +++ b/apps/web/src/components/domains/DomainDialogV2.tsx @@ -222,7 +222,7 @@ export function DomainDialogV2({ open, onOpenChange, domain, onSave }: DomainDia }; onSave(domainData); - onOpenChange(false); + // Do not close dialog here - let parent component handle it after successful save }; return ( diff --git a/apps/web/src/components/forms/NLBFormDialog.tsx b/apps/web/src/components/forms/NLBFormDialog.tsx index 1e6bef8..f0a02ae 100644 --- a/apps/web/src/components/forms/NLBFormDialog.tsx +++ b/apps/web/src/components/forms/NLBFormDialog.tsx @@ -1,7 +1,14 @@ import { useEffect, useState } from 'react'; import { useForm, useFieldArray, Controller } from 'react-hook-form'; import { useCreateNLB, useUpdateNLB } from '@/queries/nlb.query-options'; -import { NetworkLoadBalancer, CreateNLBInput, NLBUpstream } from '@/types'; +import { NetworkLoadBalancer, CreateNLBInput } from '@/types'; +import { + validateNLBConfig, + isValidNLBName, + validateUpstreamHost, + getValidationHints, + checkConfigurationWarnings, +} from '@/utils/nlb-validators'; import { Dialog, DialogContent, @@ -30,7 +37,7 @@ import { import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Card, CardContent } from '@/components/ui/card'; -import { Plus, Trash2, HelpCircle } from 'lucide-react'; +import { Plus, Trash2, HelpCircle, AlertTriangle } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; interface NLBFormDialogProps { @@ -46,6 +53,8 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia const { toast } = useToast(); const createMutation = useCreateNLB(); const updateMutation = useUpdateNLB(); + const [configWarnings, setConfigWarnings] = useState([]); + const [validationErrors, setValidationErrors] = useState([]); const { register, @@ -82,6 +91,27 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia }); const protocol = watch('protocol'); + const upstreams = watch('upstreams'); + const proxyTimeout = watch('proxyTimeout'); + const proxyConnectTimeout = watch('proxyConnectTimeout'); + const healthCheckEnabled = watch('healthCheckEnabled'); + const healthCheckInterval = watch('healthCheckInterval'); + const healthCheckTimeout = watch('healthCheckTimeout'); + + // Check for configuration warnings whenever form values change + useEffect(() => { + if (upstreams && upstreams.length > 0) { + const warnings = checkConfigurationWarnings({ + upstreams: upstreams, + proxyTimeout: proxyTimeout || 3, + proxyConnectTimeout: proxyConnectTimeout || 1, + healthCheckEnabled: healthCheckEnabled || false, + healthCheckInterval: healthCheckInterval, + healthCheckTimeout: healthCheckTimeout, + }); + setConfigWarnings(warnings); + } + }, [upstreams, proxyTimeout, proxyConnectTimeout, healthCheckEnabled, healthCheckInterval, healthCheckTimeout]); useEffect(() => { if (isOpen && nlb && mode === 'edit') { @@ -136,6 +166,65 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia const onSubmit = async (data: FormData) => { try { + // Validate complete configuration before submission + const validation = validateNLBConfig({ + name: data.name, + port: Number(data.port), + upstreams: data.upstreams.map(u => ({ + host: u.host, + port: Number(u.port), + weight: Number(u.weight), + maxFails: Number(u.maxFails), + failTimeout: Number(u.failTimeout), + maxConns: Number(u.maxConns), + backup: Boolean(u.backup), + down: Boolean(u.down), + })), + proxyTimeout: Number(data.proxyTimeout), + proxyConnectTimeout: Number(data.proxyConnectTimeout), + proxyNextUpstreamTimeout: Number(data.proxyNextUpstreamTimeout), + proxyNextUpstreamTries: Number(data.proxyNextUpstreamTries), + healthCheckEnabled: Boolean(data.healthCheckEnabled), + healthCheckInterval: Number(data.healthCheckInterval), + healthCheckTimeout: Number(data.healthCheckTimeout), + healthCheckRises: Number(data.healthCheckRises), + healthCheckFalls: Number(data.healthCheckFalls), + }); + + if (!validation.valid) { + const errorMessages = Object.entries(validation.errors) + .map(([field, error]) => { + // Format field names to be more user-friendly + const fieldNames: Record = { + name: 'Name', + port: 'Port', + upstreams: 'Upstreams', + proxyTimeout: 'Proxy Timeout', + proxyConnectTimeout: 'Proxy Connect Timeout', + proxyNextUpstreamTimeout: 'Next Upstream Timeout', + proxyNextUpstreamTries: 'Next Upstream Tries', + healthCheckInterval: 'Health Check Interval', + healthCheckTimeout: 'Health Check Timeout', + healthCheckRises: 'Health Check Rises', + healthCheckFalls: 'Health Check Falls', + }; + const friendlyField = fieldNames[field] || field; + return `${friendlyField}: ${error}`; + }); + + setValidationErrors(errorMessages); + + toast({ + title: 'Configuration Error', + description: `Please fix ${errorMessages.length} validation error${errorMessages.length > 1 ? 's' : ''} before submitting.`, + variant: 'destructive', + }); + return; + } + + // Clear validation errors if everything is valid + setValidationErrors([]); + // Convert all string numbers to actual numbers const processedData = { ...data, @@ -177,18 +266,41 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia } onClose(); } catch (error: any) { - const message = error.response?.data?.message; - let description = `Failed to ${mode} NLB`; + console.error('NLB submission error:', error); + + const response = error.response?.data; + let errorMessages: string[] = []; + let title = 'Error'; - if (message?.includes('already exists')) { - description = 'An NLB with this name already exists'; - } else if (message) { - description = message; + // Handle validation errors from backend + if (response?.errors && Array.isArray(response.errors)) { + title = 'Validation Error'; + errorMessages = response.errors.map((err: any) => { + if (err.msg && err.path) { + return `${err.path}: ${err.msg}`; + } + return err.msg || err.message || 'Unknown error'; + }); + setValidationErrors(errorMessages); + } else if (response?.message) { + // Handle single error message + if (response.message.includes('already exists')) { + errorMessages = ['An NLB with this name already exists. Please choose a different name.']; + } else if (response.message.includes('host not found') || response.message.includes('Invalid host')) { + errorMessages = ['Invalid upstream host. Please check your IP address or hostname format.']; + } else if (response.message.includes('nginx')) { + errorMessages = ['Nginx configuration error: ' + response.message]; + } else { + errorMessages = [response.message]; + } + setValidationErrors(errorMessages); + } else { + errorMessages = [`Failed to ${mode} NLB. Please check your configuration and try again.`]; } toast({ - title: 'Error', - description, + title, + description: errorMessages[0] || `Failed to ${mode} NLB`, variant: 'destructive', }); } @@ -198,6 +310,14 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia append({ host: '', port: 80, weight: 1, maxFails: 3, failTimeout: 10, maxConns: 0, backup: false, down: false }); }; + // Clear validation errors when dialog closes + useEffect(() => { + if (!isOpen) { + setValidationErrors([]); + setConfigWarnings([]); + } + }, [isOpen]); + return ( !open && onClose()}> @@ -209,6 +329,31 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia
+ {/* Validation Errors Alert */} + {validationErrors.length > 0 && ( +
+
+ +
+

+ Configuration Errors ({validationErrors.length}) +

+
    + {validationErrors.map((error, idx) => ( +
  • + + {error} +
  • + ))} +
+

+ Please fix these errors before submitting the form. +

+
+
+
+ )} + Basic @@ -221,12 +366,21 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia { + const validation = isValidNLBName(value); + return validation.valid || validation.error || 'Invalid name'; + }, + })} + placeholder="my-load-balancer" /> {errors.name && (

{errors.name.message}

)} +

+ {getValidationHints('name')} +

@@ -249,11 +403,15 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia required: 'Port is required', min: { value: 10000, message: 'Port must be ≥ 10000' }, max: { value: 65535, message: 'Port must be ≤ 65535' }, + valueAsNumber: true, })} /> {errors.port && (

{errors.port.message}

)} +

+ {getValidationHints('port')} +

@@ -326,14 +484,23 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia { + const validation = validateUpstreamHost(value); + return validation.valid || validation.error || 'Invalid host'; + }, })} - placeholder="192.168.1.100" + placeholder="192.168.1.100 or backend.example.com" /> {errors.upstreams?.[index]?.host && (

{errors.upstreams[index]?.host?.message}

)} + {!errors.upstreams?.[index]?.host && ( +

+ {getValidationHints('host')} +

+ )}
@@ -342,10 +509,16 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia type="number" {...register(`upstreams.${index}.port`, { required: 'Port is required', - min: 1, - max: 65535, + min: { value: 1, message: 'Port must be ≥ 1' }, + max: { value: 65535, message: 'Port must be ≤ 65535' }, + valueAsNumber: true, })} /> + {errors.upstreams?.[index]?.port && ( +

+ {errors.upstreams[index]?.port?.message} +

+ )}
@@ -355,26 +528,50 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia + {errors.upstreams?.[index]?.weight && ( +

+ {errors.upstreams[index]?.weight?.message} +

+ )}
+ {errors.upstreams?.[index]?.maxFails && ( +

+ {errors.upstreams[index]?.maxFails?.message} +

+ )}
+ {errors.upstreams?.[index]?.failTimeout && ( +

+ {errors.upstreams[index]?.failTimeout?.message} +

+ )}
@@ -383,9 +580,18 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia + {errors.upstreams?.[index]?.maxConns && ( +

+ {errors.upstreams[index]?.maxConns?.message} +

+ )} @@ -448,6 +654,25 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia At least one upstream is required

)} + + {/* Configuration Warnings */} + {configWarnings.length > 0 && ( +
+
+ +
+

+ Configuration Warnings +

+
    + {configWarnings.map((warning, idx) => ( +
  • • {warning}
  • + ))} +
+
+
+
+ )} @@ -456,12 +681,38 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia
- + + {errors.proxyTimeout && ( +

{errors.proxyTimeout.message}

+ )} +

+ {getValidationHints('proxyTimeout')} +

- + + {errors.proxyConnectTimeout && ( +

{errors.proxyConnectTimeout.message}

+ )} +

+ {getValidationHints('proxyConnectTimeout')} +

@@ -487,18 +738,32 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia + {errors.proxyNextUpstreamTimeout && ( +

{errors.proxyNextUpstreamTimeout.message}

+ )}
+ {errors.proxyNextUpstreamTries && ( +

{errors.proxyNextUpstreamTries.message}

+ )}
@@ -525,25 +790,68 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia + {errors.healthCheckInterval && ( +

{errors.healthCheckInterval.message}

+ )} +

+ {getValidationHints('healthCheckInterval')} +

- + + {errors.healthCheckTimeout && ( +

{errors.healthCheckTimeout.message}

+ )} +

+ {getValidationHints('healthCheckTimeout')} +

- + + {errors.healthCheckRises && ( +

{errors.healthCheckRises.message}

+ )}
- + + {errors.healthCheckFalls && ( +

{errors.healthCheckFalls.message}

+ )}
diff --git a/apps/web/src/components/logs/LogDetailsDialog.tsx b/apps/web/src/components/logs/LogDetailsDialog.tsx index 1b5e0cb..1417126 100644 --- a/apps/web/src/components/logs/LogDetailsDialog.tsx +++ b/apps/web/src/components/logs/LogDetailsDialog.tsx @@ -48,7 +48,7 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr return ( - + Log Details @@ -65,7 +65,7 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr {/* Basic Information */}

Basic Information

-
+
Source: {log.source}
@@ -93,7 +93,7 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr {(log.method || log.path || log.uri || log.statusCode) && (

Request Information

-
+
{log.method && (
Method:{" "} @@ -101,43 +101,49 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr
)} {log.path && ( -
- Path:{" "} - - {log.path} - +
+ Path: +
+ + {log.path} + +
)} {log.uri && ( -
- URI:{" "} - - {log.uri} - -
- )} - {log.statusCode && ( -
- Status Code:{" "} - = 500 - ? "destructive" - : log.statusCode >= 400 - ? "outline" - : "default" - } - > - {log.statusCode} - -
- )} - {log.responseTime && ( -
- Response Time:{" "} - {log.responseTime}ms +
+ URI: +
+ + {log.uri} + +
)} +
+ {log.statusCode && ( +
+ Status Code:{" "} + = 500 + ? "destructive" + : log.statusCode >= 400 + ? "outline" + : "default" + } + > + {log.statusCode} + +
+ )} + {log.responseTime && ( +
+ Response Time:{" "} + {log.responseTime}ms +
+ )} +
)} @@ -186,11 +192,13 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr
)} {log.file && ( -
- Rule File: - - {log.file} - +
+ Rule File: +
+ + {log.file} + +
)} {log.line && ( @@ -208,11 +216,13 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr
)} {log.data && ( -
- Data: - - {log.data} - +
+ Data: +
+ + {log.data} + +
)}
@@ -223,7 +233,7 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr

Message

-

{log.message}

+

{log.message}

@@ -231,8 +241,8 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr {log.fullMessage && (

Complete Log Entry

-
-
+                
+
                     {log.fullMessage}
                   
diff --git a/apps/web/src/components/modsec/CustomRuleDialog.tsx b/apps/web/src/components/modsec/CustomRuleDialog.tsx index 52d6e1b..fd5244f 100644 --- a/apps/web/src/components/modsec/CustomRuleDialog.tsx +++ b/apps/web/src/components/modsec/CustomRuleDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; @@ -11,21 +11,43 @@ import { DialogTitle, DialogFooter, } from '@/components/ui/dialog'; +import { ExternalLink } from 'lucide-react'; import { toast } from 'sonner'; -import { useAddModSecRule } from '@/queries/modsec.query-options'; +import { useAddModSecRule, useUpdateModSecRule } from '@/queries/modsec.query-options'; +import type { ModSecurityCustomRule } from '@/types'; interface CustomRuleDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + editRule?: ModSecurityCustomRule | null; } -export function CustomRuleDialog({ open, onOpenChange }: CustomRuleDialogProps) { +export function CustomRuleDialog({ open, onOpenChange, editRule }: CustomRuleDialogProps) { const addCustomRuleMutation = useAddModSecRule(); + const updateCustomRuleMutation = useUpdateModSecRule(); const [name, setName] = useState(''); const [category, setCategory] = useState(''); const [ruleContent, setRuleContent] = useState(''); const [description, setDescription] = useState(''); + const isEditMode = !!editRule; + + // Load rule data when editing + useEffect(() => { + if (editRule) { + setName(editRule.name); + setCategory(editRule.category); + setRuleContent(editRule.ruleContent || ''); + setDescription(editRule.description || ''); + } else { + // Reset form when not editing + setName(''); + setCategory(''); + setRuleContent(''); + setDescription(''); + } + }, [editRule, open]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -35,24 +57,39 @@ export function CustomRuleDialog({ open, onOpenChange }: CustomRuleDialogProps) } try { - await addCustomRuleMutation.mutateAsync({ - name: name.trim(), - category: category.trim(), - ruleContent: ruleContent.trim(), - description: description.trim() || undefined, - enabled: true, - }); - - toast.success('Custom rule added successfully'); - onOpenChange(false); - - // Reset form - setName(''); - setCategory(''); - setRuleContent(''); - setDescription(''); + if (isEditMode && editRule) { + // Update existing rule + await updateCustomRuleMutation.mutateAsync({ + id: editRule.id, + data: { + name: name.trim(), + category: category.trim(), + ruleContent: ruleContent.trim(), + description: description.trim() || undefined, + }, + }); + toast.success('Custom rule updated successfully'); + // Only close dialog on success + onOpenChange(false); + } else { + // Add new rule + await addCustomRuleMutation.mutateAsync({ + name: name.trim(), + category: category.trim(), + ruleContent: ruleContent.trim(), + description: description.trim() || undefined, + enabled: true, + }); + toast.success('Custom rule added successfully'); + // Only close dialog on success + onOpenChange(false); + } } catch (error: any) { - toast.error(error?.response?.data?.message || 'Failed to add custom rule'); + const errorMessage = error?.response?.data?.message || `Failed to ${isEditMode ? 'update' : 'add'} custom rule`; + toast.error(errorMessage, { + duration: 5000, + }); + // Do not close dialog on error - keep form open for user to fix issues } }; @@ -69,10 +106,32 @@ SecRule REQUEST_FILENAME "@contains /admin" \\ - Add Custom ModSecurity Rule + {isEditMode ? 'Edit' : 'Add'} Custom ModSecurity Rule Write custom ModSecurity rules using SecRule directives +
+
+ +
+

+ Need to create whitelist rules? +

+

+ Use the ModSecurity Whitelist Generator to parse raw logs and generate whitelist rules automatically. +

+ + Open Whitelist Generator + + +
+
+
@@ -153,11 +212,22 @@ SecRule ARGS "@detectSQLi" \\
- - diff --git a/apps/web/src/components/pages/ACL.tsx b/apps/web/src/components/pages/ACL.tsx index ffd37b5..9363db3 100644 --- a/apps/web/src/components/pages/ACL.tsx +++ b/apps/web/src/components/pages/ACL.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Suspense } from "react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -9,11 +9,14 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { Plus, Download, Upload, Trash2, Edit, Loader2, UserCog } from "lucide-react"; +import { Plus, Download, Upload, Trash2, Edit, Loader2, UserCog, AlertCircle, CheckCircle2, Info, FileCode } from "lucide-react"; import { ACLRule } from "@/types"; import { useToast } from "@/hooks/use-toast"; import { SkeletonTable } from "@/components/ui/skeletons"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { validateAclValue, getValidationHints, getExampleValue } from "@/utils/acl-validators"; +import { PreviewConfigDialog } from "@/components/acl/PreviewConfigDialog"; import { useSuspenseAclRules, useCreateAclRule, @@ -30,6 +33,7 @@ function AclRulesTable() { const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingRule, setEditingRule] = useState(null); const [ruleToDelete, setRuleToDelete] = useState<{ id: string; name: string } | null>(null); + const [previewOpen, setPreviewOpen] = useState(false); const createAclRule = useCreateAclRule(); const updateAclRule = useUpdateAclRule(); @@ -47,7 +51,81 @@ function AclRulesTable() { enabled: true }); + const [validationError, setValidationError] = useState(null); + const [validationSuccess, setValidationSuccess] = useState(false); + + // Real-time validation when value changes + useEffect(() => { + if (formData.value.trim().length === 0) { + setValidationError(null); + setValidationSuccess(false); + return; + } + + const result = validateAclValue(formData.field, formData.operator, formData.value); + if (result.valid) { + setValidationError(null); + setValidationSuccess(true); + } else { + setValidationError(result.error || 'Invalid value'); + setValidationSuccess(false); + } + }, [formData.value, formData.field, formData.operator]); + + // Auto-adjust action based on type + useEffect(() => { + if (formData.type === 'whitelist' && formData.action === 'deny') { + setFormData(prev => ({ ...prev, action: 'allow' })); + } else if (formData.type === 'blacklist' && formData.action === 'allow') { + setFormData(prev => ({ ...prev, action: 'deny' })); + } + }, [formData.type]); + + // Reset validation when field or operator changes + useEffect(() => { + setValidationError(null); + setValidationSuccess(false); + if (formData.value.trim().length > 0) { + const result = validateAclValue(formData.field, formData.operator, formData.value); + if (result.valid) { + setValidationSuccess(true); + } else { + setValidationError(result.error || 'Invalid value'); + } + } + }, [formData.field, formData.operator]); + const handleAddRule = async () => { + // Validate before submission + if (!formData.name.trim()) { + toast({ + title: "Validation Error", + description: "Rule name is required", + variant: "destructive" + }); + return; + } + + if (!formData.value.trim()) { + toast({ + title: "Validation Error", + description: "Condition value is required", + variant: "destructive" + }); + return; + } + + // Validate value + const valueValidation = validateAclValue(formData.field, formData.operator, formData.value); + if (!valueValidation.valid) { + toast({ + title: "Validation Error", + description: valueValidation.error || "Invalid condition value", + variant: "destructive" + }); + return; + } + // Transform field format: user-agent -> user_agent for backend const conditionField = formData.field.replace('-', '_') as any; @@ -101,6 +179,8 @@ function AclRulesTable() { action: "deny", enabled: true }); + setValidationError(null); + setValidationSuccess(false); }; const handleEdit = (rule: ACLRule) => { @@ -190,6 +270,10 @@ function AclRulesTable() {
+
- setFormData({ ...formData, value: e.target.value })} - placeholder="e.g., 192.168.1.100" - /> +
+ setFormData({ ...formData, value: e.target.value })} + placeholder={getExampleValue(formData.field, formData.operator)} + className={validationError ? 'border-red-500' : validationSuccess ? 'border-green-500' : ''} + /> + {validationSuccess && formData.value.trim().length > 0 && ( + + )} + {validationError && ( + + )} +
+ + {/* Validation feedback */} + {validationError && ( + + + {validationError} + + )} + + {/* Hints */} + + + + Hint: {getValidationHints(formData.field, formData.operator)} + +
- @@ -387,20 +505,17 @@ function AclRulesTable() { !open && setRuleToDelete(null)} - title="Delete ACL Rule" - description={ - <> - Are you sure you want to delete the rule {ruleToDelete?.name}? -
- This action cannot be undone and may affect access control to your domains. - - } - confirmText="Delete Rule" - cancelText="Cancel" onConfirm={handleDelete} - isLoading={deleteAclRule.isPending} + title="Delete ACL Rule" + description={`Are you sure you want to delete the rule "${ruleToDelete?.name}"? This action cannot be undone.`} + confirmText="Delete" variant="destructive" /> + + ); } diff --git a/apps/web/src/components/pages/DashboardNew.tsx b/apps/web/src/components/pages/DashboardNew.tsx new file mode 100644 index 0000000..3ce5dec --- /dev/null +++ b/apps/web/src/components/pages/DashboardNew.tsx @@ -0,0 +1,697 @@ +import { useTranslation } from "react-i18next"; +import { Suspense, useState } from "react"; +import { + LayoutDashboard, + Globe, + AlertTriangle, + CheckCircle2, + Activity, + Shield, + TrendingUp, + Clock, + Users, + Eye, +} from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Legend, + ResponsiveContainer, +} from "recharts"; +import { + useSuspenseDashboardStats, + useSuspenseRequestTrend, + useSuspenseSlowRequests, + useSuspenseLatestAttackStats, + useSuspenseLatestNews, + useSuspenseRequestAnalytics, + useSuspenseAttackRatio, +} from "@/queries"; +import { SkeletonStatsCard, SkeletonChart, SkeletonTable } from "@/components/ui/skeletons"; + +// Constants for status codes and colors +const STATUS_CODES_CONFIG = [ + { key: "status200", color: "#22c55e", label: "dashboard.status200" }, + { key: "status301", color: "#3b82f6", label: "dashboard.status301" }, + { key: "status302", color: "#06b6d4", label: "dashboard.status302" }, + { key: "status400", color: "#f59e0b", label: "dashboard.status400" }, + { key: "status403", color: "#f97316", label: "dashboard.status403" }, + { key: "status404", color: "#eab308", label: "dashboard.status404" }, + { key: "status500", color: "#ef4444", label: "dashboard.status500" }, + { key: "status502", color: "#dc2626", label: "dashboard.status502" }, + { key: "status503", color: "#b91c1c", label: "dashboard.status503" }, +] as const; + +// Helper function to format time +const formatTime = (date: Date) => + `${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`; + +// Helper function to get attack percentage color +const getAttackPercentageColor = (percentage: number) => { + if (percentage > 10) return "text-destructive"; + if (percentage > 5) return "text-warning"; + return "text-success"; +}; + +// Helper function to get severity badge variant +const getSeverityVariant = (severity: string): "destructive" | "default" => { + return severity === "CRITICAL" || severity === "2" ? "destructive" : "default"; +}; + +// Reusable Empty State Component +const EmptyState = ({ message }: { message: string }) => ( +
+ {message} +
+); + +// Reusable Count Badge Component +const CountBadge = ({ count, variant = "destructive" }: { count: number; variant?: "destructive" | "secondary" }) => ( + count > 0 ? ( + {count} + ) : ( + 0 + ) +); + +// Reusable List Item Component +const ListItem = ({ + title, + subtitle, + badge +}: { + title: string; + subtitle: string; + badge: React.ReactNode; +}) => ( +
+
+

{title}

+

{subtitle}

+
+ {badge} +
+); + +// Reusable Card Header with Icon +const CardHeaderWithIcon = ({ + icon: Icon, + title, + description +}: { + icon: any; + title: string; + description?: string; +}) => ( + + + + {title} + + {description && {description}} + +); + +// Reusable Metric Row Component +const MetricRow = ({ + label, + value, + valueClassName = "" +}: { + label: string; + value: React.ReactNode; + valueClassName?: string; +}) => ( +
+ {label} + {value} +
+); + +// Reusable Data Card Component +const DataCard = ({ + icon, + title, + description, + data, + emptyMessage, + children +}: { + icon: any; + title: string; + description?: string; + data: any; + emptyMessage: string; + children: (data: any) => React.ReactNode; +}) => ( + + + + {data && (Array.isArray(data) ? data.length > 0 : true) ? ( + children(data) + ) : ( + + )} + + +); + +// Reusable Table Card Component +const TableCard = ({ + icon, + title, + description, + data, + emptyMessage, + headers, + renderRow, + maxHeight = "max-h-[400px]" +}: { + icon: any; + title: string; + description?: string; + data: any[]; + emptyMessage: string; + headers: TableHeader[]; + renderRow: (item: any) => React.ReactNode; + maxHeight?: string; +}) => ( + + {(items) => ( +
+ + + + {headers.map(({ key, label, width, align }) => ( + + {label} + + ))} + + + + {items.map(renderRow)} + +
+
+ )} +
+); + +// Component for stats overview +function DashboardStatsOverview() { + const { t } = useTranslation(); + const { data: stats } = useSuspenseDashboardStats(); + + const activeDomains = stats?.domains.active || 0; + const errorDomains = stats?.domains.errors || 0; + const unacknowledgedAlerts = stats?.alerts.unacknowledged || 0; + const criticalAlerts = stats?.alerts.critical || 0; + + const statsCards = [ + { + title: t("dashboard.domains"), + value: stats?.domains.total || 0, + description: `${activeDomains} active, ${errorDomains} errors`, + icon: Globe, + color: "text-primary", + }, + { + title: t("dashboard.traffic"), + value: stats?.traffic.requestsPerDay || "0", + description: "Requests/day", + icon: LayoutDashboard, + color: "text-success", + }, + { + title: t("dashboard.errors"), + value: errorDomains, + description: "Domains with issues", + icon: AlertTriangle, + color: "text-destructive", + }, + { + title: t("dashboard.uptime"), + value: `${stats?.uptime || "0"}%`, + description: "Last 30 days", + icon: CheckCircle2, + color: "text-success", + }, + ]; + + return ( + <> + {unacknowledgedAlerts > 0 && ( + + + + + Active Alerts + + + +

+ You have {unacknowledgedAlerts} unacknowledged alerts + {criticalAlerts > 0 && `, including ${criticalAlerts} critical`}. +

+
+
+ )} + +
+ {statsCards.map((stat) => ( + + + {stat.title} + + + +
{stat.value}
+

{stat.description}

+
+
+ ))} +
+ + ); +} + +// Component for Request Trend Chart +function RequestTrendChart() { + const { t } = useTranslation(); + const { data: trendData } = useSuspenseRequestTrend(5); + + // Generate chart config dynamically + const chartConfig = Object.fromEntries( + STATUS_CODES_CONFIG.map(({ key, color, label }) => [ + key, + { label: t(label), color } + ]) + ); + + return ( + + +
+
+ + + {t("dashboard.requestTrend")} + + {t("dashboard.requestTrendDesc")} +
+
+
+ + {trendData && trendData.length > 0 ? ( + + + + + formatTime(new Date(value))} + /> + + new Date(label).toLocaleString()} + /> + } + /> + + {STATUS_CODES_CONFIG.map(({ key, color, label }) => ( + + ))} + + + + ) : ( + + )} + +
+ ); +} + +// Component for Slow Requests +function SlowRequestsCard() { + const { t } = useTranslation(); + const { data: slowRequests } = useSuspenseSlowRequests(10); + + return ( + + {(data) => ( +
+ {data.slice(0, 3).map((req: any, idx: number) => ( +
+
+

{req.path}

+

+ {req.requestCount} requests +

+
+ + {req.avgResponseTime.toFixed(2)}ms + +
+ ))} +
+ )} +
+ ); +} + +// Component for Attack Ratio +function AttackRatioCard() { + const { t } = useTranslation(); + const { data: attackRatio } = useSuspenseAttackRatio(); + + const metrics = [ + { label: "dashboard.attackRequests", value: attackRatio?.attackRequests, variant: "destructive" as const }, + { label: "dashboard.normalRequests", value: attackRatio?.normalRequests, variant: "secondary" as const }, + ]; + + return ( + + {(data) => ( +
+ +
+ {metrics.map(({ label, value, variant }) => ( +
+ {t(label)} + {value?.toLocaleString()} +
+ ))} +
+
+ +
+
+ )} +
+ ); +} + +// Component for Latest Attacks +function LatestAttacksCard() { + const { t } = useTranslation(); + const { data: attacks } = useSuspenseLatestAttackStats(5); + + return ( + + {(data) => ( +
+ {data.map((attack: any, idx: number) => ( + {attack.count}} + /> + ))} +
+ )} +
+ ); +} + +// Table headers configuration +type TableHeader = { key: string; label: string; width?: string; align?: string }; + +const NEWS_TABLE_HEADERS: TableHeader[] = [ + { key: "timestamp", label: "dashboard.timestamp", width: "w-[140px]" }, + { key: "attackerIp", label: "dashboard.attackerIp", width: "w-[120px]" }, + { key: "domain", label: "dashboard.domain", width: "w-[140px]" }, + { key: "attackType", label: "dashboard.attackType" }, + { key: "action", label: "dashboard.action" }, + { key: "actions", label: "dashboard.actions", align: "text-right" }, +]; + +const IP_TABLE_HEADERS: TableHeader[] = [ + { key: "sourceIp", label: "dashboard.sourceIp" }, + { key: "requestCount", label: "dashboard.requestCount", align: "text-right" }, + { key: "errors", label: "Errors", align: "text-right" }, + { key: "attacks", label: "Attacks", align: "text-right" }, +]; + +// Component for Latest News Table +function LatestNewsTable() { + const { t } = useTranslation(); + const { data: news } = useSuspenseLatestNews(10); + + const handleViewDetails = (item: any) => { + const url = item.uniqueId + ? `/logs?uniqueId=${encodeURIComponent(item.uniqueId)}` + : `/logs?search=${encodeURIComponent(item.ruleId || item.attackType)}`; + window.location.href = url; + }; + + const headers = NEWS_TABLE_HEADERS.map(h => ({ ...h, label: t(h.label) })); + + return ( + ( + + + {new Date(item.timestamp).toLocaleString()} + + {item.attackerIp} + {item.domain || '-'} + {item.attackType} + {item.action} + + + + + )} + /> + ); +} + +// Component for Request Analytics (IP Analytics) +function RequestAnalyticsCard() { + const { t } = useTranslation(); + const [period, setPeriod] = useState<'day' | 'week' | 'month'>('day'); + const { data: analytics } = useSuspenseRequestAnalytics(period); + + const periods = ['day', 'week', 'month'] as const; + const headers = IP_TABLE_HEADERS.map(h => ({ + ...h, + label: h.key === 'sourceIp' || h.key === 'requestCount' ? t(h.label) : h.label + })); + + return ( + + +
+
+ + + {t("dashboard.requestAnalytics")} + + {t("dashboard.requestAnalyticsDesc")} +
+ +
+
+ + {analytics && analytics.topIps.length > 0 ? ( +
+ + + + {headers.map(({ key, label, align }) => ( + + {label} + + ))} + + + + {analytics.topIps.map((ip: any, idx: number) => ( + + {ip.ip} + + {ip.requestCount.toLocaleString()} + + + + + + + + + ))} + +
+
+ ) : ( + + )} +
+
+ ); +} + +// Main Dashboard component with Suspense boundaries +export default function DashboardNew() { + const { t } = useTranslation(); + + return ( +
+
+
+
+ +
+
+

+ {t("dashboard.title")} +

+

{t("dashboard.overview")}

+
+
+
+ + {/* Stats Overview */} + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ } + > + + + + {/* Dashboard Rows */} + {[ + { + cols: "lg:grid-cols-3", + items: [ + { component: , fallback: , className: "lg:col-span-2" }, + { component: , fallback: }, + ] + }, + { + cols: "lg:grid-cols-2", + items: [ + { component: , fallback: }, + { component: , fallback: }, + ] + }, + { + cols: "lg:grid-cols-2", + items: [ + { component: , fallback: }, + { component: , fallback: }, + ] + }, + ].map((row, rowIdx) => ( +
+ {row.items.map((item, itemIdx) => ( + +
+ {item.component} +
+
+ ))} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/pages/Domains.tsx b/apps/web/src/components/pages/Domains.tsx index e15148e..663c55c 100644 --- a/apps/web/src/components/pages/Domains.tsx +++ b/apps/web/src/components/pages/Domains.tsx @@ -527,15 +527,23 @@ export default function Domains() { try { if (editingDomain) { await updateDomain.mutateAsync({ id: editingDomain.id, data: domainData }); - toast.success(`Domain ${domainData.name} updated`); + toast.success(`Domain ${domainData.name} updated successfully`); + // Only close dialog on success + setDialogOpen(false); + setEditingDomain(null); } else { await createDomain.mutateAsync(domainData); - toast.success(`Domain ${domainData.name} created`); + toast.success(`Domain ${domainData.name} created successfully`); + // Only close dialog on success + setDialogOpen(false); + setEditingDomain(null); } - setDialogOpen(false); - setEditingDomain(null); } catch (error: any) { - toast.error(error.message || 'Failed to save domain'); + const errorMessage = error?.response?.data?.message || error.message || 'Failed to save domain'; + toast.error(errorMessage, { + duration: 5000, + }); + // Do not close dialog on error - keep form open for user to fix issues } }; diff --git a/apps/web/src/components/pages/Logs.tsx b/apps/web/src/components/pages/Logs.tsx index bb5d875..daf348d 100644 --- a/apps/web/src/components/pages/Logs.tsx +++ b/apps/web/src/components/pages/Logs.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, Suspense } from "react"; +import { useState, useEffect, useMemo, useCallback, Suspense } from "react"; import { useTranslation } from "react-i18next"; import { ColumnDef, @@ -62,9 +62,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { LogEntry } from "@/types"; -import { - downloadLogs, -} from "@/services/logs.service"; +import { downloadLogs } from "@/services/logs.service"; import { useToast } from "@/hooks/use-toast"; import { SkeletonStatsCard, SkeletonTable } from "@/components/ui/skeletons"; import { @@ -74,73 +72,308 @@ import { useLogs } from "@/queries/logs.query-options"; import { LogDetailsDialog } from "@/components/logs/LogDetailsDialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +// Constants +const LEVEL_OPTIONS = [ + { value: "all", label: "All Levels" }, + { value: "info", label: "Info" }, + { value: "warning", label: "Warning" }, + { value: "error", label: "Error" }, +] as const; + +const TYPE_OPTIONS = [ + { value: "all", label: "All Types" }, + { value: "access", label: "Access" }, + { value: "error", label: "Error" }, + { value: "system", label: "System" }, +] as const; + +const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50] as const; + +const STATS_CONFIG = [ + { key: "total", label: "Total Logs", color: "", description: "All log entries" }, + { key: "info", label: "Info Logs", color: "text-blue-500", description: "Information messages", path: "byLevel.info" }, + { key: "warning", label: "Warning Logs", color: "text-yellow-500", description: "Warning messages", path: "byLevel.warning" }, + { key: "error", label: "Error Logs", color: "text-red-500", description: "Error messages", path: "byLevel.error" }, +] as const; + +// Helper functions +const getLevelColor = (level: string): "destructive" | "default" | "secondary" | "outline" => { + const colorMap = { + error: "destructive" as const, + warning: "outline" as const, + info: "default" as const, + }; + return colorMap[level as keyof typeof colorMap] || "secondary"; +}; + +const getTypeColor = (type: string) => { + const colorMap = { + access: "default" as const, + error: "destructive" as const, + system: "secondary" as const, + }; + return colorMap[type as keyof typeof colorMap] || "outline"; +}; + +// Get nested value from object path +const getNestedValue = (obj: any, path: string) => { + return path.split('.').reduce((acc, part) => acc?.[part], obj); +}; -// Component for fast-loading statistics data +// Build filter parameters helper +const buildFilterParams = (filters: { + level: string; + type: string; + domain: string; + search: string; + ruleId: string; + uniqueId: string; + page?: number; + limit?: number; +}) => { + const params: any = {}; + + if (filters.page) params.page = filters.page; + if (filters.limit) params.limit = filters.limit; + + const filterConfigs = [ + { key: 'level', value: filters.level, exclude: 'all' }, + { key: 'type', value: filters.type, exclude: 'all' }, + { key: 'domain', value: filters.domain, exclude: 'all' }, + { key: 'search', value: filters.search }, + { key: 'ruleId', value: filters.ruleId }, + { key: 'uniqueId', value: filters.uniqueId }, + ]; + + filterConfigs.forEach(({ key, value, exclude }) => { + if (value && value !== exclude) params[key] = value; + }); + + return params; +}; + +// Truncatable text component +const TruncatableText = ({ text, maxLength = 40 }: { text: string; maxLength?: number }) => { + if (text.length <= maxLength) return
{text}
; + + return ( + + +
+ {text.substring(0, maxLength)}... +
+
+ + {text} + +
+ ); +}; + +// Log details renderer component +const LogDetailsCell = ({ log }: { log: LogEntry }) => { + const details = [ + { condition: log.ip, label: "IP", value: log.ip }, + { + condition: log.method && log.path, + value: `${log.method} ${log.path}`, + truncate: true + }, + { condition: log.statusCode, label: "Status", value: log.statusCode }, + { condition: log.responseTime, label: "RT", value: `${log.responseTime}ms` }, + { + condition: log.ruleId, + label: "Rule ID", + value: log.ruleId, + className: "font-semibold text-red-600" + }, + { condition: log.severity, label: "Severity", value: log.severity }, + { + condition: log.tags?.length, + label: "Tags", + value: log.tags?.join(', '), + truncate: true + }, + { condition: log.uri, label: "URI", value: log.uri, truncate: true }, + ].filter(detail => detail.condition); + + return ( +
+ {details.map((detail, idx) => { + const content = detail.label ? `${detail.label}: ${detail.value}` : detail.value; + const displayContent = detail.truncate ? ( + + ) : ( +
{content}
+ ); + + return
{displayContent}
; + })} +
+ ); +}; + +// Statistics card component +const StatCard = ({ stat, value }: { stat: typeof STATS_CONFIG[number]; value: number }) => ( + + + {stat.label} + + +
+ {value.toLocaleString()} +
+

+ {stat.description} +

+
+
+); + +// Statistics component const LogStatistics = () => { const { data: stats } = useSuspenseLogStatistics(); return (
- - - Total Logs - - -
- {stats.total.toLocaleString()} -
-

- All log entries -

-
-
- - - - Info Logs - - -
- {stats.byLevel.info.toLocaleString()} -
-

- Information messages -

-
-
- - - - Warning Logs - - -
- {stats.byLevel.warning.toLocaleString()} -
-

- Warning messages -

-
-
- - - - Error Logs - - -
- {stats.byLevel.error.toLocaleString()} -
-

- Error messages -

-
-
+ {STATS_CONFIG.map((stat) => { + const value = stat.path + ? getNestedValue(stats, stat.path) + : stats[stat.key as keyof typeof stats]; + return ; + })}
); }; -// Component for deferred log entries data +// Custom event dispatcher hook +const useCustomEvent = (eventName: string, handler: (value: any) => void) => { + useEffect(() => { + const handleEvent = (e: any) => handler(e.detail); + window.addEventListener(eventName, handleEvent); + return () => window.removeEventListener(eventName, handleEvent); + }, [eventName, handler]); +}; + +const dispatchCustomEvent = (eventName: string, value: any) => { + window.dispatchEvent(new CustomEvent(eventName, { detail: value })); +}; + +// Filter component +const FilterInput = ({ + placeholder, + value, + eventName, + className = "" +}: { + placeholder: string; + value: string; + eventName: string; + className?: string; +}) => ( + dispatchCustomEvent(eventName, e.target.value)} + onPaste={(e) => { + e.preventDefault(); + const text = e.clipboardData.getData('text/plain'); + dispatchCustomEvent(eventName, text); + }} + className={className} + /> +); + +// Filter select component +const FilterSelect = ({ + value, + options, + eventName, + className = "", + placeholder = "Select" +}: { + value: string; + options: readonly { value: string; label: string }[]; + eventName: string; + className?: string; + placeholder?: string; +}) => ( + +); + +// Skeleton row component +const SkeletonRow = ({ columnCount }: { columnCount: number }) => ( + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+); + +// Pagination button component +const PaginationButton = ({ + onClick, + disabled, + icon: Icon, + label, + className = "" +}: { + onClick: () => void; + disabled: boolean; + icon: any; + label: string; + className?: string; +}) => ( + +); + +// Log entries component const LogEntries = ({ page, limit, @@ -148,6 +381,8 @@ const LogEntries = ({ level, type, domain, + ruleId, + uniqueId, setPage, setLimit, sorting, @@ -159,7 +394,6 @@ const LogEntries = ({ rowSelection, setRowSelection, autoRefresh, - setAutoRefresh, toast, onRefetch, selectedLog, @@ -171,6 +405,8 @@ const LogEntries = ({ level: string; type: string; domain: string; + ruleId: string; + uniqueId: string; setPage: (page: number) => void; setLimit: (limit: number) => void; sorting: SortingState; @@ -182,107 +418,51 @@ const LogEntries = ({ rowSelection: Record; setRowSelection: (selection: Record) => void; autoRefresh: boolean; - setAutoRefresh: (refresh: boolean) => void; toast: any; onRefetch: (refetch: () => Promise) => void; selectedLog: LogEntry | null; setSelectedLog: (log: LogEntry | null) => void; }) => { const [isPageChanging, setIsPageChanging] = useState(false); - // Build query parameters - const params: any = { - page, - limit, - }; + + const stableUniqueId = useMemo(() => uniqueId ? String(uniqueId) : "", [uniqueId]); + + const params = useMemo(() => + buildFilterParams({ + level, type, domain, search, ruleId, + uniqueId: stableUniqueId, + page, limit + }), + [page, limit, level, type, domain, search, ruleId, stableUniqueId] + ); - if (level !== "all") { - params.level = level; - } - if (type !== "all") { - params.type = type; - } - if (domain !== "all") { - params.domain = domain; - } - if (search) { - params.search = search; - } - - // Use regular query instead of suspense query for better control const { data: logsResponse, refetch, isFetching, isLoading } = useLogs(params); const logs = logsResponse?.data || []; const pagination = logsResponse?.pagination || { total: 0, totalPages: 1 }; - // Get domains for filter const { data: domains } = useSuspenseAvailableDomains(); - // Pass refetch function to parent component useEffect(() => { onRefetch(refetch); }, [refetch, onRefetch]); - // Auto refresh effect useEffect(() => { if (!autoRefresh) return; - - const interval = setInterval(() => { - refetch(); - }, 5000); // Refresh every 5 seconds - + const interval = setInterval(refetch, 5000); return () => clearInterval(interval); }, [autoRefresh, refetch]); - // Update page changing state based on isFetching useEffect(() => { - setIsPageChanging(isFetching && !isLoading); // Only show skeleton when refetching, not initial load + setIsPageChanging(isFetching && !isLoading); }, [isFetching, isLoading]); - const getLevelColor = ( - level: string - ): "destructive" | "default" | "secondary" | "outline" => { - switch (level) { - case "error": - return "destructive"; - case "warning": - return "outline"; - case "info": - return "default"; - default: - return "secondary"; - } - }; - - const getTypeColor = (type: string) => { - switch (type) { - case "access": - return "default"; - case "error": - return "destructive"; - case "system": - return "secondary"; - default: - return "outline"; - } - }; - - const handleDownloadLogs = async () => { + const handleDownloadLogs = useCallback(async () => { try { - const params: any = { limit: 1000 }; - - if (level !== "all") { - params.level = level; - } - if (type !== "all") { - params.type = type; - } - if (domain !== "all") { - params.domain = domain; - } - if (search) { - params.search = search; - } - - await downloadLogs(params); + const downloadParams = buildFilterParams({ + level, type, domain, search, ruleId, uniqueId, + limit: 1000 + }); + await downloadLogs(downloadParams); toast({ title: "Success", description: "Logs downloaded successfully", @@ -295,9 +475,9 @@ const LogEntries = ({ variant: "destructive", }); } - }; + }, [level, type, domain, search, ruleId, uniqueId, toast]); - // Define columns for the table + // Define columns const columns: ColumnDef[] = [ { accessorKey: "timestamp", @@ -325,9 +505,6 @@ const LogEntries = ({ {row.getValue("level")} ), - filterFn: (row, id, value) => { - return value === "all" || row.getValue(id) === value; - }, }, { accessorKey: "type", @@ -337,16 +514,11 @@ const LogEntries = ({ {row.getValue("type")} ), - filterFn: (row, id, value) => { - return value === "all" || row.getValue(id) === value; - }, }, { accessorKey: "source", header: "Source", - cell: ({ row }) => ( -
{row.getValue("source")}
- ), + cell: ({ row }) =>
{row.getValue("source")}
, }, { accessorKey: "domain", @@ -354,16 +526,11 @@ const LogEntries = ({ cell: ({ row }) => { const domain = row.getValue("domain") as string; return domain ? ( - - {domain} - + {domain} ) : ( - ); }, - filterFn: (row, id, value) => { - return value === "all" || row.getValue(id) === value; - }, }, { accessorKey: "message", @@ -371,11 +538,12 @@ const LogEntries = ({ cell: ({ row }) => { const log = row.original; const displayMessage = log.fullMessage || log.message; + const hasMore = log.fullMessage && log.fullMessage.length > log.message.length; + return (
- {/* Show truncated version in table, full message in title tooltip */}
{log.message}
- {log.fullMessage && log.fullMessage.length > log.message.length && ( + {hasMore && (
Click for full details
@@ -387,38 +555,10 @@ const LogEntries = ({ { accessorKey: "details", header: "Details", - cell: ({ row }) => { - const log = row.original; - return ( -
- {log.ip &&
IP: {log.ip}
} - {log.method && log.path && ( -
- {log.method} {log.path} -
- )} - {log.statusCode &&
Status: {log.statusCode}
} - {log.responseTime &&
RT: {log.responseTime}ms
} - {/* ModSecurity specific details */} - {log.ruleId && ( -
Rule ID: {log.ruleId}
- )} - {log.severity && ( -
Severity: {log.severity}
- )} - {log.tags && log.tags.length > 0 && ( -
- Tags: {log.tags.join(', ')} -
- )} - {log.uri &&
URI: {log.uri}
} -
- ); - }, + cell: ({ row }) => , }, ]; - // Create table instance const table = useReactTable({ data: logs, columns, @@ -438,10 +578,7 @@ const LogEntries = ({ columnFilters, columnVisibility, rowSelection, - pagination: { - pageIndex: page - 1, - pageSize: limit, - }, + pagination: { pageIndex: page - 1, pageSize: limit }, }, }); @@ -455,105 +592,79 @@ const LogEntries = ({ {/* Filters */} -
- {/* Search */} -
+
+
- { - // This will be handled by the parent component - const event = new CustomEvent('searchChange', { detail: e.target.value }); - window.dispatchEvent(event); - }} + eventName="searchChange" className="pl-10" />
- {/* Filters */} -
- - - - - - - - - - - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - > - {column.id} - - ); - })} - - -
+ + + + + + + + + + + + + + + + {table.getAllColumns().filter((col) => col.getCanHide()).map((col) => ( + col.toggleVisibility(!!value)} + > + {col.id} + + ))} + +
+ + {/* Table */}
@@ -561,12 +672,7 @@ const LogEntries = ({ {headerGroup.headers.map((header) => ( - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ))} @@ -574,117 +680,25 @@ const LogEntries = ({ {(isLoading || isPageChanging) ? ( - // Show skeleton rows for initial load or page changes - Array.from({ length: limit }).map((_, index) => ( - - -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
- )) + Array.from({ length: limit }).map((_, i) => ) ) : logs.length > 0 ? ( logs.map((log, index) => ( setSelectedLog(log)} > - - {new Date(log.timestamp).toLocaleString()} - - - - {log.level} - - - - - {log.type} - - - - {log.source} - - - {log.domain ? ( - - {log.domain} - - ) : ( - - - - - )} - - -
- {log.message} -
- {log.fullMessage && log.fullMessage.length > log.message.length && ( -
- Click for full details -
- )} -
- - {log.ip &&
IP: {log.ip}
} - {log.method && log.path && ( -
- {log.method} {log.path} -
- )} - {log.statusCode &&
Status: {log.statusCode}
} - {log.responseTime && ( -
RT: {log.responseTime}ms
- )} - {/* ModSecurity specific details */} - {log.ruleId && ( -
- Rule ID: {log.ruleId} -
- )} - {log.severity && ( -
Severity: {log.severity}
- )} - {log.tags && log.tags.length > 0 && ( -
- Tags: {log.tags.join(', ')} -
- )} - {log.uri &&
URI: {log.uri}
} -
+ {table.getRowModel().rows[index]?.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))}
)) ) : ( - + No logs found. @@ -703,19 +717,15 @@ const LogEntries = ({ value={`${limit}`} onValueChange={(value) => { setLimit(Number(value)); - setPage(1); // Reset to first page when changing page size + setPage(1); }} > - + - {[10, 20, 30, 40, 50].map((pageSize) => ( - - {pageSize} - + {PAGE_SIZE_OPTIONS.map((size) => ( + {size} ))} @@ -724,42 +734,14 @@ const LogEntries = ({ Page {page} of {pagination.totalPages || 1}
- - - - + {[ + { icon: ChevronsLeft, onClick: () => setPage(1), disabled: page === 1, label: "Go to first page", className: "hidden lg:flex" }, + { icon: ChevronLeft, onClick: () => setPage(page - 1), disabled: page === 1, label: "Go to previous page" }, + { icon: ChevronRight, onClick: () => setPage(page + 1), disabled: page === pagination.totalPages, label: "Go to next page" }, + { icon: ChevronsRight, onClick: () => setPage(pagination.totalPages || 1), disabled: page === pagination.totalPages, label: "Go to last page", className: "hidden lg:flex" }, + ].map((btn, idx) => ( + + ))}
@@ -768,7 +750,7 @@ const LogEntries = ({ ); }; -// Main Logs component +// Main component const Logs = () => { const { t } = useTranslation(); const { toast } = useToast(); @@ -777,28 +759,30 @@ const Logs = () => { const [isReloading, setIsReloading] = useState(false); const [selectedLog, setSelectedLog] = useState(null); - // URL state management with nuqs + // URL state const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1)); - const [limit, setLimit] = useQueryState( - "limit", - parseAsInteger.withDefault(10) - ); - const [search, setSearch] = useQueryState( - "search", - parseAsString.withDefault("") - ); - const [level, setLevel] = useQueryState( - "level", - parseAsString.withDefault("all") - ); - const [type, setType] = useQueryState( - "type", - parseAsString.withDefault("all") - ); - const [domain, setDomain] = useQueryState( - "domain", - parseAsString.withDefault("all") - ); + const [limit, setLimit] = useQueryState("limit", parseAsInteger.withDefault(10)); + const [search, setSearch] = useQueryState("search", parseAsString.withDefault("")); + const [level, setLevel] = useQueryState("level", parseAsString.withDefault("all")); + const [type, setType] = useQueryState("type", parseAsString.withDefault("all")); + const [domain, setDomain] = useQueryState("domain", parseAsString.withDefault("all")); + const [ruleId, setRuleId] = useQueryState("ruleId", parseAsString.withDefault("")); + + const [uniqueId, setUniqueIdState] = useState(() => { + if (typeof window !== 'undefined') { + return new URLSearchParams(window.location.search).get('uniqueId') || ""; + } + return ""; + }); + + const setUniqueId = useCallback((value: string) => { + setUniqueIdState(value); + if (typeof window !== 'undefined') { + const url = new URL(window.location.href); + value ? url.searchParams.set('uniqueId', value) : url.searchParams.delete('uniqueId'); + window.history.replaceState({}, '', url.toString()); + } + }, []); // Table state const [sorting, setSorting] = useState([]); @@ -806,49 +790,22 @@ const Logs = () => { const [columnVisibility, setColumnVisibility] = useState({}); const [rowSelection, setRowSelection] = useState>({}); - // Handle custom events for filter changes from LogEntries component - useEffect(() => { - const handleSearchChange = (e: any) => setSearch(e.detail); - const handleDomainChange = (e: any) => setDomain(e.detail); - const handleLevelChange = (e: any) => setLevel(e.detail); - const handleTypeChange = (e: any) => setType(e.detail); - - window.addEventListener('searchChange', handleSearchChange); - window.addEventListener('domainChange', handleDomainChange); - window.addEventListener('levelChange', handleLevelChange); - window.addEventListener('typeChange', handleTypeChange); - - return () => { - window.removeEventListener('searchChange', handleSearchChange); - window.removeEventListener('domainChange', handleDomainChange); - window.removeEventListener('levelChange', handleLevelChange); - window.removeEventListener('typeChange', handleTypeChange); - }; - }, [setSearch, setDomain, setLevel, setType]); - - - const handleDownloadLogs = async () => { + // Custom event handlers + useCustomEvent('searchChange', setSearch); + useCustomEvent('domainChange', setDomain); + useCustomEvent('levelChange', setLevel); + useCustomEvent('typeChange', setType); + useCustomEvent('ruleIdChange', setRuleId); + useCustomEvent('uniqueIdChange', setUniqueId); + + const handleDownloadLogs = useCallback(async () => { try { - const params: any = { limit: 1000 }; - - if (level !== "all") { - params.level = level; - } - if (type !== "all") { - params.type = type; - } - if (domain !== "all") { - params.domain = domain; - } - if (search) { - params.search = search; - } - - await downloadLogs(params); - toast({ - title: "Success", - description: "Logs downloaded successfully", + const downloadParams = buildFilterParams({ + level, type, domain, search, ruleId, uniqueId, + limit: 1000 }); + await downloadLogs(downloadParams); + toast({ title: "Success", description: "Logs downloaded successfully" }); } catch (error: any) { console.error("Failed to download logs:", error); toast({ @@ -857,11 +814,10 @@ const Logs = () => { variant: "destructive", }); } - }; + }, [level, type, domain, search, ruleId, uniqueId, toast]); return (
- {/* Header with action buttons */}
@@ -880,9 +836,7 @@ const Logs = () => { size="sm" onClick={() => setAutoRefresh(!autoRefresh)} > - + Auto Refresh -
- {/* Fast-loading statistics data - loaded immediately via route loader */} - - - - + {Array.from({ length: 4 }).map((_, i) => )}
}> - {/* Deferred log entries data - loaded after initial render */} { level={level} type={type} domain={domain} + ruleId={ruleId} + uniqueId={uniqueId || ""} setPage={setPage} setLimit={setLimit} sorting={sorting} @@ -944,14 +891,12 @@ const Logs = () => { rowSelection={rowSelection} setRowSelection={setRowSelection} autoRefresh={autoRefresh} - setAutoRefresh={setAutoRefresh} toast={toast} onRefetch={(refetch) => setLogsRefetch(() => refetch)} selectedLog={selectedLog} setSelectedLog={setSelectedLog} /> - {/* Log Details Dialog */} { ); }; -export default Logs; +export default Logs; \ No newline at end of file diff --git a/apps/web/src/components/pages/ModSecurity.tsx b/apps/web/src/components/pages/ModSecurity.tsx index d17c9bb..1efa72f 100644 --- a/apps/web/src/components/pages/ModSecurity.tsx +++ b/apps/web/src/components/pages/ModSecurity.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Shield, Plus } from 'lucide-react'; +import { Shield, Plus, Pencil, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Switch } from '@/components/ui/switch'; @@ -15,6 +15,16 @@ import { TableRow, } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { CustomRuleDialog } from '@/components/modsec/CustomRuleDialog'; import { toast } from 'sonner'; import { @@ -24,11 +34,15 @@ import { useToggleCrsRule, useToggleModSecRule, useSetGlobalModSec, + useDeleteModSecRule, } from '@/queries/modsec.query-options'; +import type { ModSecurityCustomRule } from '@/types'; export default function ModSecurity() { const { t } = useTranslation(); const [customRuleDialogOpen, setCustomRuleDialogOpen] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [deletingRuleId, setDeletingRuleId] = useState(null); // Queries const { data: crsRules = [] } = useCrsRules(); @@ -39,6 +53,7 @@ export default function ModSecurity() { const toggleCrsRuleMutation = useToggleCrsRule(); const toggleCustomRuleMutation = useToggleModSecRule(); const setGlobalModSecMutation = useSetGlobalModSec(); + const deleteCustomRuleMutation = useDeleteModSecRule(); const globalModSecEnabled = globalSettings?.enabled ?? true; @@ -69,6 +84,30 @@ export default function ModSecurity() { } }; + const handleEditRule = (rule: ModSecurityCustomRule) => { + setEditingRule(rule); + setCustomRuleDialogOpen(true); + }; + + const handleDeleteRule = async () => { + if (!deletingRuleId) return; + + try { + await deleteCustomRuleMutation.mutateAsync(deletingRuleId); + toast.success('Custom rule deleted successfully'); + setDeletingRuleId(null); + } catch (error) { + toast.error('Failed to delete custom rule'); + } + }; + + const handleDialogClose = (open: boolean) => { + setCustomRuleDialogOpen(open); + if (!open) { + setEditingRule(null); + } + }; + return (
@@ -180,12 +219,13 @@ export default function ModSecurity() { Description Status Enable + Actions {customRules.length === 0 ? ( - + No custom rules. Click "Add Custom Rule" to create one. @@ -210,6 +250,24 @@ export default function ModSecurity() { onCheckedChange={() => handleCustomRuleToggle(rule.id, rule.name, rule.enabled)} /> + +
+ + +
+
)) )} @@ -223,8 +281,26 @@ export default function ModSecurity() { + + !open && setDeletingRuleId(null)}> + + + Delete Custom Rule + + Are you sure you want to delete this custom rule? This action cannot be undone. + + + + Cancel + + Delete + + + +
); } diff --git a/apps/web/src/components/pages/index.ts b/apps/web/src/components/pages/index.ts index 84e7ada..6d94bc5 100644 --- a/apps/web/src/components/pages/index.ts +++ b/apps/web/src/components/pages/index.ts @@ -4,6 +4,7 @@ export { default as Account } from './Account'; export { default as Alerts } from './Alerts'; export { default as Backup } from './Backup'; export { default as Dashboard } from './Dashboard'; +export { default as DashboardNew } from './DashboardNew'; export { default as Domains } from './Domains'; export { default as Login } from './Login'; export { default as Logs } from './Logs'; diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts index 0ef340c..7cb3b8f 100644 --- a/apps/web/src/lib/i18n.ts +++ b/apps/web/src/lib/i18n.ts @@ -18,6 +18,7 @@ const resources = { 'nav.users': 'User Management', 'nav.nodes': 'Slave Nodes', 'nav.network': 'Network Manager', + 'nav.plugins': 'Plugins', // Login 'login.title': 'Admin Portal', @@ -34,6 +35,53 @@ const resources = { 'dashboard.errors': 'Errors', 'dashboard.uptime': 'Uptime', + // Dashboard Analytics + 'dashboard.requestTrend': 'Request Trend', + 'dashboard.requestTrendDesc': 'Real-time request statistics by HTTP status', + 'dashboard.slowRequests': 'Slow Requests', + 'dashboard.slowRequestsDesc': 'Top 10 slowest URL paths', + 'dashboard.latestAttacks': 'Latest Attacks', + 'dashboard.latestAttacksDesc': 'Top 5 attack types in 24 hours', + 'dashboard.latestNews': 'Latest Security Events', + 'dashboard.latestNewsDesc': 'Recent security incidents', + 'dashboard.requestAnalytics': 'Request Analytics', + 'dashboard.requestAnalyticsDesc': 'Top 10 IP addresses by period', + 'dashboard.attackRatio': 'Attack vs Normal Requests', + 'dashboard.attackRatioDesc': 'Security threat ratio', + 'dashboard.path': 'Path', + 'dashboard.avgResponseTime': 'Avg Response Time', + 'dashboard.requestCount': 'Request Count', + 'dashboard.attackType': 'Attack Type', + 'dashboard.count': 'Count', + 'dashboard.severity': 'Severity', + 'dashboard.lastOccurred': 'Last Occurred', + 'dashboard.timestamp': 'Timestamp', + 'dashboard.attackerIp': 'Attacker IP', + 'dashboard.domain': 'Target Domain', + 'dashboard.urlPath': 'URL Path', + 'dashboard.action': 'Action', + 'dashboard.viewDetails': 'View Details', + 'dashboard.actions': 'Actions', + 'dashboard.sourceIp': 'Source IP', + 'dashboard.totalRequests': 'Total Requests', + 'dashboard.attackRequests': 'Attack Requests', + 'dashboard.normalRequests': 'Normal Requests', + 'dashboard.attackPercentage': 'Attack Percentage', + 'dashboard.period.day': 'Today', + 'dashboard.period.week': 'This Week', + 'dashboard.period.month': 'This Month', + 'dashboard.status200': '200 OK', + 'dashboard.status301': '301 Redirect', + 'dashboard.status302': '302 Redirect', + 'dashboard.status400': '400 Bad Request', + 'dashboard.status403': '403 Forbidden', + 'dashboard.status404': '404 Not Found', + 'dashboard.status500': '500 Server Error', + 'dashboard.status502': '502 Bad Gateway', + 'dashboard.status503': '503 Service Unavailable', + 'dashboard.statusOther': 'Other', + 'dashboard.noData': 'No data available', + // Domains 'domains.title': 'Domain Management', 'domains.add': 'Add Domain', @@ -77,6 +125,7 @@ const resources = { 'nav.users': 'Quản lý người dùng', 'nav.nodes': 'Nút phụ', 'nav.network': 'Quản lý mạng', + 'nav.plugins': 'Plugin', // Login 'login.title': 'Cổng Quản Trị', @@ -93,6 +142,53 @@ const resources = { 'dashboard.errors': 'Lỗi', 'dashboard.uptime': 'Thời gian hoạt động', + // Dashboard Analytics + 'dashboard.requestTrend': 'Xu hướng Request', + 'dashboard.requestTrendDesc': 'Thống kê request theo trạng thái HTTP', + 'dashboard.slowRequests': 'Request Chậm', + 'dashboard.slowRequestsDesc': 'Top 10 URL path chậm nhất', + 'dashboard.latestAttacks': 'Tấn công mới nhất', + 'dashboard.latestAttacksDesc': 'Top 5 loại tấn công trong 24h', + 'dashboard.latestNews': 'Sự kiện bảo mật', + 'dashboard.latestNewsDesc': 'Sự cố bảo mật gần đây', + 'dashboard.requestAnalytics': 'Phân tích Request', + 'dashboard.requestAnalyticsDesc': 'Top 10 địa chỉ IP theo thời gian', + 'dashboard.attackRatio': 'Tỷ lệ Tấn công', + 'dashboard.attackRatioDesc': 'Tỷ lệ mối đe dọa bảo mật', + 'dashboard.path': 'Đường dẫn', + 'dashboard.avgResponseTime': 'Thời gian phản hồi TB', + 'dashboard.requestCount': 'Số lượng request', + 'dashboard.attackType': 'Loại tấn công', + 'dashboard.count': 'Số lượng', + 'dashboard.severity': 'Mức độ nghiêm trọng', + 'dashboard.lastOccurred': 'Lần cuối xảy ra', + 'dashboard.timestamp': 'Thời gian', + 'dashboard.attackerIp': 'IP tấn công', + 'dashboard.domain': 'Domain đích', + 'dashboard.urlPath': 'Đường dẫn URL', + 'dashboard.action': 'Hành động', + 'dashboard.viewDetails': 'Xem chi tiết', + 'dashboard.actions': 'Thao tác', + 'dashboard.sourceIp': 'IP nguồn', + 'dashboard.totalRequests': 'Tổng số request', + 'dashboard.attackRequests': 'Request tấn công', + 'dashboard.normalRequests': 'Request bình thường', + 'dashboard.attackPercentage': 'Tỷ lệ tấn công', + 'dashboard.period.day': 'Hôm nay', + 'dashboard.period.week': 'Tuần này', + 'dashboard.period.month': 'Tháng này', + 'dashboard.status200': '200 OK', + 'dashboard.status301': '301 Chuyển hướng', + 'dashboard.status302': '302 Chuyển hướng', + 'dashboard.status400': '400 Yêu cầu không hợp lệ', + 'dashboard.status403': '403 Bị cấm', + 'dashboard.status404': '404 Không tìm thấy', + 'dashboard.status500': '500 Lỗi máy chủ', + 'dashboard.status502': '502 Bad Gateway', + 'dashboard.status503': '503 Dịch vụ không khả dụng', + 'dashboard.statusOther': 'Khác', + 'dashboard.noData': 'Không có dữ liệu', + // Domains 'domains.title': 'Quản lý tên miền', 'domains.add': 'Thêm tên miền', diff --git a/apps/web/src/queries/acl.query-options.ts b/apps/web/src/queries/acl.query-options.ts index d106f3a..e8968f3 100644 --- a/apps/web/src/queries/acl.query-options.ts +++ b/apps/web/src/queries/acl.query-options.ts @@ -19,6 +19,12 @@ export const aclQueryOptions = { queryKey: aclQueryKeys.detail(id), queryFn: () => aclService.getById(id), }), + + // Preview ACL configuration + preview: { + queryKey: [...aclQueryKeys.lists(), 'preview'], + queryFn: aclService.preview, + }, }; // Suspense query options for ACL rules @@ -174,6 +180,10 @@ export const useApplyAclRules = () => { return useMutation(aclMutationOptions.apply); }; +export const usePreviewAclConfig = () => { + return useQuery(aclQueryOptions.preview); +}; + // Hook to preload ACL rules data export const usePreloadAclRules = () => { const queryClient = useQueryClient(); diff --git a/apps/web/src/queries/dashboard-analytics.query-options.ts b/apps/web/src/queries/dashboard-analytics.query-options.ts new file mode 100644 index 0000000..3fa101b --- /dev/null +++ b/apps/web/src/queries/dashboard-analytics.query-options.ts @@ -0,0 +1,117 @@ +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { dashboardAnalyticsService } from '@/services/dashboard-analytics.service'; +import { createQueryKeys } from '@/lib/query-client'; + +// Create query keys for dashboard analytics +export const dashboardAnalyticsQueryKeys = createQueryKeys('dashboardAnalytics'); + +// Query options for dashboard analytics +export const dashboardAnalyticsQueryOptions = { + // Get request trend (auto-refresh every 5 seconds) + requestTrend: (intervalSeconds: number = 5) => ({ + queryKey: dashboardAnalyticsQueryKeys.list({ interval: intervalSeconds }), + queryFn: () => dashboardAnalyticsService.getRequestTrend(intervalSeconds), + refetchInterval: 5 * 1000, // Auto-refresh every 5 seconds + staleTime: 3 * 1000, // Consider stale after 3 seconds + }), + + // Get slow requests + slowRequests: (limit: number = 10) => ({ + queryKey: dashboardAnalyticsQueryKeys.list({ type: 'slow', limit }), + queryFn: () => dashboardAnalyticsService.getSlowRequests(limit), + refetchInterval: 30 * 1000, // Auto-refresh every 30 seconds + }), + + // Get latest attack statistics + latestAttacks: (limit: number = 5) => ({ + queryKey: dashboardAnalyticsQueryKeys.list({ type: 'attacks', limit }), + queryFn: () => dashboardAnalyticsService.getLatestAttackStats(limit), + refetchInterval: 10 * 1000, // Auto-refresh every 10 seconds + }), + + // Get latest security news/events + latestNews: (limit: number = 20) => ({ + queryKey: dashboardAnalyticsQueryKeys.list({ type: 'news', limit }), + queryFn: () => dashboardAnalyticsService.getLatestNews(limit), + refetchInterval: 10 * 1000, // Auto-refresh every 10 seconds + }), + + // Get request analytics (top IPs by period) + requestAnalytics: (period: 'day' | 'week' | 'month' = 'day') => ({ + queryKey: dashboardAnalyticsQueryKeys.list({ type: 'ipAnalytics', period }), + queryFn: () => dashboardAnalyticsService.getRequestAnalytics(period), + refetchInterval: 60 * 1000, // Auto-refresh every minute + }), + + // Get attack vs normal request ratio + attackRatio: () => ({ + queryKey: dashboardAnalyticsQueryKeys.detail('attackRatio'), + queryFn: dashboardAnalyticsService.getAttackRatio, + refetchInterval: 30 * 1000, // Auto-refresh every 30 seconds + }), + + // Get complete dashboard analytics + complete: () => ({ + queryKey: dashboardAnalyticsQueryKeys.detail('complete'), + queryFn: dashboardAnalyticsService.getDashboardAnalytics, + refetchInterval: 10 * 1000, // Auto-refresh every 10 seconds + }), +}; + +// Custom hooks for dashboard analytics operations +export const useRequestTrend = (intervalSeconds: number = 5) => { + return useQuery(dashboardAnalyticsQueryOptions.requestTrend(intervalSeconds)); +}; + +export const useSlowRequests = (limit: number = 10) => { + return useQuery(dashboardAnalyticsQueryOptions.slowRequests(limit)); +}; + +export const useLatestAttackStats = (limit: number = 5) => { + return useQuery(dashboardAnalyticsQueryOptions.latestAttacks(limit)); +}; + +export const useLatestNews = (limit: number = 20) => { + return useQuery(dashboardAnalyticsQueryOptions.latestNews(limit)); +}; + +export const useRequestAnalytics = (period: 'day' | 'week' | 'month' = 'day') => { + return useQuery(dashboardAnalyticsQueryOptions.requestAnalytics(period)); +}; + +export const useAttackRatio = () => { + return useQuery(dashboardAnalyticsQueryOptions.attackRatio()); +}; + +export const useDashboardAnalytics = () => { + return useQuery(dashboardAnalyticsQueryOptions.complete()); +}; + +// Suspense hooks for deferred loading pattern +export const useSuspenseRequestTrend = (intervalSeconds: number = 5) => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.requestTrend(intervalSeconds)); +}; + +export const useSuspenseSlowRequests = (limit: number = 10) => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.slowRequests(limit)); +}; + +export const useSuspenseLatestAttackStats = (limit: number = 5) => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.latestAttacks(limit)); +}; + +export const useSuspenseLatestNews = (limit: number = 20) => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.latestNews(limit)); +}; + +export const useSuspenseRequestAnalytics = (period: 'day' | 'week' | 'month' = 'day') => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.requestAnalytics(period)); +}; + +export const useSuspenseAttackRatio = () => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.attackRatio()); +}; + +export const useSuspenseDashboardAnalytics = () => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.complete()); +}; diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index 3b22567..36669ac 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -1,6 +1,7 @@ // Export all query options and hooks export * from './auth.query-options'; export * from './dashboard.query-options'; +export * from './dashboard-analytics.query-options'; export * from './domain.query-options'; export * from './acl.query-options'; export * from './alerts.query-options'; diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index bce9d13..9929a22 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -1,21 +1,29 @@ -import Dashboard from '@/components/pages/Dashboard' +import DashboardNew from '@/components/pages/DashboardNew' import { createFileRoute } from '@tanstack/react-router' -import { dashboardQueryOptions } from '@/queries/dashboard.query-options' +import { dashboardQueryOptions, dashboardAnalyticsQueryOptions } from '@/queries' export const Route = createFileRoute('/_auth/dashboard')({ component: RouteComponent, loader: async ({ context }) => { const { queryClient } = context - // Prefetch all dashboard data but don't await it (allow it to load in background) + // Prefetch dashboard stats queryClient.prefetchQuery(dashboardQueryOptions.stats) queryClient.prefetchQuery(dashboardQueryOptions.systemMetrics('24h')) queryClient.prefetchQuery(dashboardQueryOptions.recentAlerts(5)) + // Prefetch dashboard analytics data + queryClient.prefetchQuery(dashboardAnalyticsQueryOptions.requestTrend(5)) + queryClient.prefetchQuery(dashboardAnalyticsQueryOptions.slowRequests(10)) + queryClient.prefetchQuery(dashboardAnalyticsQueryOptions.latestAttacks(5)) + queryClient.prefetchQuery(dashboardAnalyticsQueryOptions.latestNews(20)) + queryClient.prefetchQuery(dashboardAnalyticsQueryOptions.requestAnalytics('day')) + queryClient.prefetchQuery(dashboardAnalyticsQueryOptions.attackRatio()) + return {} }, }) function RouteComponent() { - return + return } diff --git a/apps/web/src/services/acl.service.ts b/apps/web/src/services/acl.service.ts index ab1c7db..937a4b5 100644 --- a/apps/web/src/services/acl.service.ts +++ b/apps/web/src/services/acl.service.ts @@ -126,6 +126,14 @@ export const aclService = { }; }, + /** + * Preview ACL configuration + */ + async preview(): Promise<{ config: string; rulesCount: number }> { + const response = await api.get<{ success: boolean; data: { config: string; rulesCount: number } }>('/acl/preview'); + return response.data.data; + }, + /** * Apply ACL rules to Nginx */ diff --git a/apps/web/src/services/dashboard-analytics.service.ts b/apps/web/src/services/dashboard-analytics.service.ts new file mode 100644 index 0000000..9b3fd0a --- /dev/null +++ b/apps/web/src/services/dashboard-analytics.service.ts @@ -0,0 +1,175 @@ +import api from './api'; + +/** + * Dashboard Analytics Types + */ + +// HTTP Status codes mapping type +type HttpStatusCodes = 200 | 301 | 302 | 400 | 403 | 404 | 500 | 502 | 503; + +// Base statistics interface for count-based metrics +interface BaseCountStats { + count: number; +} + +// Base timestamp interface +interface TimestampedEntry { + timestamp: string; +} + +// Request metrics with min/max/avg pattern +interface RequestMetrics { + requestCount: number; + avgResponseTime: number; + maxResponseTime: number; + minResponseTime: number; +} + +// Request trend data point with dynamic status codes +export interface RequestTrendDataPoint extends TimestampedEntry { + total: number; + status200: number; + status301: number; + status302: number; + status400: number; + status403: number; + status404: number; + status500: number; + status502: number; + status503: number; + statusOther: number; +} + +// Slow request entry +export interface SlowRequestEntry extends RequestMetrics { + path: string; +} + +// Attack type statistics +export interface AttackTypeStats extends BaseCountStats { + attackType: string; + severity: string; + lastOccurred: string; + ruleIds: string[]; +} + +// Latest attack/security event +export interface LatestAttackEntry extends TimestampedEntry { + id: string; + attackerIp: string; + domain?: string; + urlPath: string; + attackType: string; + ruleId?: string; + severity?: string; + action: string; + logId: string; +} + +// IP analytics entry with count-based metrics +export interface IpAnalyticsEntry { + ip: string; + requestCount: number; + errorCount: number; + attackCount: number; + lastSeen: string; + userAgent?: string; +} + +// Attack vs Normal request ratio +export interface AttackRatioStats { + totalRequests: number; + attackRequests: number; + normalRequests: number; + attackPercentage: number; +} + +// Period type for analytics +export type AnalyticsPeriod = 'day' | 'week' | 'month'; + +// Request analytics response +export interface RequestAnalyticsResponse { + period: AnalyticsPeriod; + topIps: IpAnalyticsEntry[]; + totalRequests: number; + uniqueIps: number; +} + +// Complete dashboard analytics response +export interface DashboardAnalyticsResponse { + requestTrend: RequestTrendDataPoint[]; + slowRequests: SlowRequestEntry[]; + latestAttacks: AttackTypeStats[]; + latestNews: LatestAttackEntry[]; + requestAnalytics: RequestAnalyticsResponse; + attackRatio: AttackRatioStats; +} + +/** + * Generic API caller to reduce duplication + */ +const fetchAnalytics = async ( + endpoint: string, + params?: Record +): Promise => { + const response = await api.get(`/dashboard/analytics/${endpoint}`, { params }); + return response.data.data; +}; + +/** + * Dashboard Analytics Service + */ +export const dashboardAnalyticsService = { + /** + * Get request trend analytics (auto-refresh every 5s) + */ + getRequestTrend: (intervalSeconds: number = 5) => + fetchAnalytics('request-trend', { interval: intervalSeconds }), + + /** + * Get slow requests from performance monitoring + */ + getSlowRequests: (limit: number = 10) => + fetchAnalytics('slow-requests', { limit }), + + /** + * Get latest attack statistics (top 5 in 24h) + */ + getLatestAttackStats: (limit: number = 5) => + fetchAnalytics('latest-attacks', { limit }), + + /** + * Get latest security news/events + */ + getLatestNews: (limit: number = 20) => + fetchAnalytics('latest-news', { limit }), + + /** + * Get request analytics (top IPs by period) + */ + getRequestAnalytics: (period: AnalyticsPeriod = 'day') => + fetchAnalytics('request-analytics', { period }), + + /** + * Get attack vs normal request ratio + */ + getAttackRatio: () => + fetchAnalytics('attack-ratio'), + + /** + * Get complete dashboard analytics (all in one) + */ + getDashboardAnalytics: () => + fetchAnalytics(''), +}; + +// Export individual functions for backward compatibility +export const getRequestTrend = dashboardAnalyticsService.getRequestTrend; +export const getSlowRequests = dashboardAnalyticsService.getSlowRequests; +export const getLatestAttackStats = dashboardAnalyticsService.getLatestAttackStats; +export const getLatestNews = dashboardAnalyticsService.getLatestNews; +export const getRequestAnalytics = dashboardAnalyticsService.getRequestAnalytics; +export const getAttackRatio = dashboardAnalyticsService.getAttackRatio; +export const getDashboardAnalytics = dashboardAnalyticsService.getDashboardAnalytics; + +export default dashboardAnalyticsService; diff --git a/apps/web/src/utils/access-list-validators.ts b/apps/web/src/utils/access-list-validators.ts new file mode 100644 index 0000000..e466a3d --- /dev/null +++ b/apps/web/src/utils/access-list-validators.ts @@ -0,0 +1,131 @@ +/** + * Access List Validation Utilities + * Provides validation for Access List form fields + */ + +import { isValidIpOrCidr } from './acl-validators'; + +/** + * Validate access list name + */ +export function validateAccessListName(name: string): { valid: boolean; error?: string } { + if (!name || name.trim().length === 0) { + return { valid: false, error: 'Name is required' }; + } + + if (name.length < 3) { + return { valid: false, error: 'Name must be at least 3 characters' }; + } + + if (name.length > 100) { + return { valid: false, error: 'Name must not exceed 100 characters' }; + } + + // Only allow letters, numbers, underscores, and hyphens + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + return { valid: false, error: 'Name can only contain letters, numbers, underscores, and hyphens' }; + } + + return { valid: true }; +} + +/** + * Validate IP address for access list + */ +export function validateAccessListIp(ip: string): { valid: boolean; error?: string } { + if (!ip || ip.trim().length === 0) { + return { valid: false, error: 'IP address is required' }; + } + + if (!isValidIpOrCidr(ip)) { + return { valid: false, error: 'Invalid IP address or CIDR notation. Examples: 192.168.1.1 or 192.168.1.0/24' }; + } + + return { valid: true }; +} + +/** + * Validate username for HTTP Basic Auth + */ +export function validateUsername(username: string): { valid: boolean; error?: string } { + if (!username || username.trim().length === 0) { + return { valid: false, error: 'Username is required' }; + } + + if (username.length < 3) { + return { valid: false, error: 'Username must be at least 3 characters' }; + } + + if (username.length > 50) { + return { valid: false, error: 'Username must not exceed 50 characters' }; + } + + // Only allow letters, numbers, underscores, and hyphens + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + return { valid: false, error: 'Username can only contain letters, numbers, underscores, and hyphens' }; + } + + return { valid: true }; +} + +/** + * Validate password for HTTP Basic Auth + */ +export function validatePassword(password: string, isRequired: boolean = true): { valid: boolean; error?: string } { + // In edit mode, password might be optional (empty = keep existing) + if (!isRequired && (!password || password.length === 0)) { + return { valid: true }; + } + + if (!password || password.trim().length === 0) { + return { valid: false, error: 'Password is required' }; + } + + if (password.length < 4) { + return { valid: false, error: 'Password must be at least 4 characters' }; + } + + return { valid: true }; +} + +/** + * Validate description + */ +export function validateDescription(description: string, maxLength: number = 500): { valid: boolean; error?: string } { + if (description && description.length > maxLength) { + return { valid: false, error: `Description must not exceed ${maxLength} characters` }; + } + + return { valid: true }; +} + +/** + * Get validation hints for access list fields + */ +export function getAccessListHints(field: string): string { + const hints: Record = { + name: 'Use only letters, numbers, underscores, and hyphens (3-100 characters)', + ip: 'Enter a valid IP address (e.g., 192.168.1.1) or CIDR notation (e.g., 192.168.1.0/24)', + username: 'Use only letters, numbers, underscores, and hyphens (3-50 characters)', + password: 'Minimum 4 characters. In edit mode, leave empty to keep existing password', + description: 'Optional description for this item (max 500 characters)' + }; + + return hints[field] || ''; +} + +/** + * Get example values for access list fields + */ +export function getAccessListExample(field: string): string { + const examples: Record = { + name: 'admin-panel-access', + ip: '192.168.1.1', + ipCidr: '192.168.1.0/24', + username: 'admin_user', + password: '••••••••', + description: 'Access for admin panel' + }; + + return examples[field] || ''; +} diff --git a/apps/web/src/utils/acl-validators.ts b/apps/web/src/utils/acl-validators.ts new file mode 100644 index 0000000..c104e02 --- /dev/null +++ b/apps/web/src/utils/acl-validators.ts @@ -0,0 +1,301 @@ +/** + * Frontend ACL Validation Utilities + * Mirrors backend validation for real-time feedback + */ + +/** + * Validate IP address (IPv4 or IPv6) + */ +export function isValidIpAddress(ip: string): boolean { + // IPv4 validation + const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + + // IPv6 validation (simplified) + const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^[0-9a-fA-F]{1,4}::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}$/; + + return ipv4Regex.test(ip) || ipv6Regex.test(ip); +} + +/** + * Validate CIDR notation (e.g., 192.168.1.0/24) + */ +export function isValidCidr(cidr: string): boolean { + const cidrRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/(?:[0-9]|[1-2][0-9]|3[0-2])$/; + + // IPv6 CIDR + const cidrV6Regex = /^(?:[0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{0,4}\/(?:[0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$/; + + return cidrRegex.test(cidr) || cidrV6Regex.test(cidr); +} + +/** + * Validate IP or CIDR + */ +export function isValidIpOrCidr(value: string): boolean { + return isValidIpAddress(value) || isValidCidr(value); +} + +/** + * Validate regex pattern + */ +export function isValidRegex(pattern: string): { valid: boolean; error?: string } { + try { + new RegExp(pattern); + return { valid: true }; + } catch (error: any) { + return { valid: false, error: error.message }; + } +} + +/** + * Validate URL pattern + */ +export function isValidUrlPattern(pattern: string): boolean { + if (!pattern || pattern.trim().length === 0) { + return false; + } + + const dangerousChars = /[;<>{}|\\]/; + if (dangerousChars.test(pattern)) { + return false; + } + + return true; +} + +/** + * Validate HTTP method + */ +export function isValidHttpMethod(method: string): boolean { + const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'CONNECT', 'TRACE']; + return validMethods.includes(method.toUpperCase()); +} + +/** + * Validate GeoIP country code + */ +export function isValidCountryCode(code: string): boolean { + return /^[A-Z]{2}$/.test(code); +} + +/** + * Validate User-Agent pattern + */ +export function isValidUserAgentPattern(pattern: string): boolean { + if (!pattern || pattern.trim().length === 0) { + return false; + } + + const dangerousChars = /[;<>{}|\\]/; + if (dangerousChars.test(pattern)) { + return false; + } + + return true; +} + +/** + * Validate header name + */ +export function isValidHeaderName(name: string): boolean { + return /^[a-zA-Z0-9\-_]+$/.test(name); +} + +/** + * Validate ACL rule value based on field and operator + */ +export function validateAclValue( + field: string, + operator: string, + value: string +): { valid: boolean; error?: string } { + if (!value || value.trim().length === 0) { + return { valid: false, error: 'Value cannot be empty' }; + } + + switch (field) { + case 'ip': + if (operator === 'equals' || operator === 'contains') { + if (!isValidIpOrCidr(value)) { + return { + valid: false, + error: 'Invalid IP address or CIDR notation. Examples: 192.168.1.1 or 192.168.1.0/24' + }; + } + } else if (operator === 'regex') { + const regexCheck = isValidRegex(value); + if (!regexCheck.valid) { + return { + valid: false, + error: `Invalid regex pattern: ${regexCheck.error}` + }; + } + } + break; + + case 'geoip': + if (operator === 'equals') { + if (!isValidCountryCode(value)) { + return { + valid: false, + error: 'Invalid country code. Use ISO 3166-1 alpha-2 format (e.g., US, CN, VN)' + }; + } + } else if (operator === 'regex') { + const regexCheck = isValidRegex(value); + if (!regexCheck.valid) { + return { + valid: false, + error: `Invalid regex pattern: ${regexCheck.error}` + }; + } + } + break; + + case 'user-agent': + if (operator === 'regex') { + const regexCheck = isValidRegex(value); + if (!regexCheck.valid) { + return { + valid: false, + error: `Invalid regex pattern: ${regexCheck.error}` + }; + } + } else if (!isValidUserAgentPattern(value)) { + return { + valid: false, + error: 'Invalid user-agent pattern. Avoid special characters like ; < > { } | \\' + }; + } + break; + + case 'url': + if (operator === 'regex') { + const regexCheck = isValidRegex(value); + if (!regexCheck.valid) { + return { + valid: false, + error: `Invalid regex pattern: ${regexCheck.error}` + }; + } + } else if (!isValidUrlPattern(value)) { + return { + valid: false, + error: 'Invalid URL pattern. Avoid special characters like ; < > { } | \\' + }; + } + break; + + case 'method': + if (operator === 'equals' && !isValidHttpMethod(value)) { + return { + valid: false, + error: 'Invalid HTTP method. Valid methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS' + }; + } + break; + + case 'header': + const headerParts = value.split(':'); + if (headerParts.length < 2) { + return { + valid: false, + error: 'Header value must be in format "Header-Name: value"' + }; + } + + const headerName = headerParts[0]?.trim() || ''; + if (!isValidHeaderName(headerName)) { + return { + valid: false, + error: 'Invalid header name. Use only alphanumeric, dash, and underscore characters' + }; + } + break; + + default: + return { valid: false, error: `Unknown field type: ${field}` }; + } + + return { valid: true }; +} + +/** + * Get validation hints for a specific field type + */ +export function getValidationHints(field: string, operator: string): string { + const hints: Record> = { + ip: { + equals: 'Enter a valid IP address (e.g., 192.168.1.1)', + contains: 'Enter a valid CIDR notation (e.g., 192.168.1.0/24)', + regex: 'Enter a valid regex pattern for IP matching' + }, + geoip: { + equals: 'Enter a 2-letter country code (e.g., US, CN, VN)', + contains: 'Enter country codes separated by comma', + regex: 'Enter a regex pattern for country codes' + }, + 'user-agent': { + equals: 'Enter exact user-agent string', + contains: 'Enter a substring to match in user-agent', + regex: 'Enter a regex pattern (e.g., (bot|crawler|spider))' + }, + url: { + equals: 'Enter exact URL path (e.g., /admin)', + contains: 'Enter a substring to match in URL', + regex: 'Enter a regex pattern (e.g., \\.(php|asp)$)' + }, + method: { + equals: 'Enter HTTP method (GET, POST, PUT, DELETE, etc.)', + contains: 'Enter HTTP method substring', + regex: 'Enter regex pattern for HTTP methods' + }, + header: { + equals: 'Enter in format "Header-Name: value"', + contains: 'Enter in format "Header-Name: value"', + regex: 'Enter in format "Header-Name: regex-pattern"' + } + }; + + return (hints[field] && hints[field][operator]) || 'Enter a valid value'; +} + +/** + * Get example values for field and operator combination + */ +export function getExampleValue(field: string, operator: string): string { + const examples: Record> = { + ip: { + equals: '192.168.1.1', + contains: '192.168.1.0/24', + regex: '^192\\.168\\.' + }, + geoip: { + equals: 'US', + contains: 'US,CN,VN', + regex: '(US|CN|VN)' + }, + 'user-agent': { + equals: 'Mozilla/5.0', + contains: 'bot', + regex: '(bot|crawler|spider)' + }, + url: { + equals: '/admin', + contains: '/api/', + regex: '\\.(php|asp)$' + }, + method: { + equals: 'POST', + contains: 'POST', + regex: '(POST|PUT|DELETE)' + }, + header: { + equals: 'X-Custom-Header: value', + contains: 'X-Custom-Header: value', + regex: 'X-Custom-Header: .*' + } + }; + + return examples[field]?.[operator] || ''; +} diff --git a/apps/web/src/utils/nlb-validators.ts b/apps/web/src/utils/nlb-validators.ts new file mode 100644 index 0000000..26948c9 --- /dev/null +++ b/apps/web/src/utils/nlb-validators.ts @@ -0,0 +1,492 @@ +/** + * Network Load Balancer Validation Utilities + * Validates NLB configuration to prevent nginx errors + */ + +/** + * Validate IP address (IPv4 or IPv6) or hostname + */ +export function isValidHost(host: string): boolean { + if (!host || host.trim().length === 0) { + return false; + } + + // Remove whitespace + host = host.trim(); + + // IPv4 validation - strict format + const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + if (ipv4Regex.test(host)) { + return true; + } + + // IPv6 validation (simplified) + const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^[0-9a-fA-F]{1,4}::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}$/; + if (ipv6Regex.test(host)) { + return true; + } + + // If it looks like an IP but failed validation, reject it + // This catches malformed IPs like "888880.8832884" + if (/^[\d.]+$/.test(host)) { + return false; // Only digits and dots but not valid IP + } + + // Hostname validation (RFC 1123) + // Must start with letter or digit, can contain letters, digits, hyphens, dots + // Each label must be 1-63 chars, total max 253 chars + // Cannot start or end with hyphen or dot + const hostnameRegex = /^(?=.{1,253}$)(?:(?!-)[A-Za-z0-9-]{1,63}(? /^\d+$/.test(label)); + if (allNumeric) { + return false; + } + return true; + } + + return false; +} + +/** + * Validate port number + */ +export function isValidPort(port: number): boolean { + return Number.isInteger(port) && port >= 1 && port <= 65535; +} + +/** + * Validate NLB listening port (must be >= 10000 to avoid conflicts) + */ +export function isValidNLBPort(port: number): boolean { + return Number.isInteger(port) && port >= 10000 && port <= 65535; +} + +/** + * Validate weight value + */ +export function isValidWeight(weight: number): boolean { + return Number.isInteger(weight) && weight >= 1 && weight <= 100; +} + +/** + * Validate max fails + */ +export function isValidMaxFails(maxFails: number): boolean { + return Number.isInteger(maxFails) && maxFails >= 0 && maxFails <= 100; +} + +/** + * Validate fail timeout (in seconds) + */ +export function isValidFailTimeout(timeout: number): boolean { + return Number.isInteger(timeout) && timeout >= 1 && timeout <= 3600; +} + +/** + * Validate max connections + */ +export function isValidMaxConns(maxConns: number): boolean { + return Number.isInteger(maxConns) && maxConns >= 0 && maxConns <= 100000; +} + +/** + * Validate proxy timeout (in seconds) + */ +export function isValidProxyTimeout(timeout: number): boolean { + return Number.isInteger(timeout) && timeout >= 1 && timeout <= 3600; +} + +/** + * Validate proxy connect timeout (in seconds) + */ +export function isValidProxyConnectTimeout(timeout: number): boolean { + return Number.isInteger(timeout) && timeout >= 1 && timeout <= 300; +} + +/** + * Validate proxy next upstream timeout (in seconds, 0 = disabled) + */ +export function isValidProxyNextUpstreamTimeout(timeout: number): boolean { + return Number.isInteger(timeout) && timeout >= 0 && timeout <= 3600; +} + +/** + * Validate proxy next upstream tries (0 = unlimited) + */ +export function isValidProxyNextUpstreamTries(tries: number): boolean { + return Number.isInteger(tries) && tries >= 0 && tries <= 100; +} + +/** + * Validate health check interval (in seconds) + */ +export function isValidHealthCheckInterval(interval: number): boolean { + return Number.isInteger(interval) && interval >= 5 && interval <= 3600; +} + +/** + * Validate health check timeout (in seconds) + */ +export function isValidHealthCheckTimeout(timeout: number): boolean { + return Number.isInteger(timeout) && timeout >= 1 && timeout <= 300; +} + +/** + * Validate health check rises + */ +export function isValidHealthCheckRises(rises: number): boolean { + return Number.isInteger(rises) && rises >= 1 && rises <= 10; +} + +/** + * Validate health check falls + */ +export function isValidHealthCheckFalls(falls: number): boolean { + return Number.isInteger(falls) && falls >= 1 && falls <= 10; +} + +/** + * Validate NLB name + */ +export function isValidNLBName(name: string): { valid: boolean; error?: string } { + if (!name || name.trim().length === 0) { + return { valid: false, error: 'Name cannot be empty' }; + } + + if (name.length < 3) { + return { valid: false, error: 'Name must be at least 3 characters' }; + } + + if (name.length > 50) { + return { valid: false, error: 'Name must not exceed 50 characters' }; + } + + // Only allow alphanumeric, dash, underscore + const nameRegex = /^[a-zA-Z0-9\-_]+$/; + if (!nameRegex.test(name)) { + return { + valid: false, + error: 'Name can only contain letters, numbers, dashes, and underscores' + }; + } + + // Cannot start or end with dash/underscore + if (/^[-_]|[-_]$/.test(name)) { + return { + valid: false, + error: 'Name cannot start or end with dash or underscore' + }; + } + + return { valid: true }; +} + +/** + * Validate upstream host + */ +export function validateUpstreamHost(host: string): { valid: boolean; error?: string } { + if (!host || host.trim().length === 0) { + return { valid: false, error: 'Host is required' }; + } + + if (!isValidHost(host)) { + return { + valid: false, + error: 'Invalid host. Enter a valid IP address (IPv4/IPv6) or hostname' + }; + } + + return { valid: true }; +} + +/** + * Validate upstream port + */ +export function validateUpstreamPort(port: number): { valid: boolean; error?: string } { + if (!port) { + return { valid: false, error: 'Port is required' }; + } + + if (!isValidPort(port)) { + return { + valid: false, + error: 'Port must be between 1 and 65535' + }; + } + + return { valid: true }; +} + +/** + * Validate upstream configuration + */ +export function validateUpstream(upstream: { + host: string; + port: number; + weight: number; + maxFails: number; + failTimeout: number; + maxConns: number; +}): { valid: boolean; errors: Record } { + const errors: Record = {}; + + // Validate host + const hostValidation = validateUpstreamHost(upstream.host); + if (!hostValidation.valid) { + errors.host = hostValidation.error || 'Invalid host'; + } + + // Validate port + const portValidation = validateUpstreamPort(upstream.port); + if (!portValidation.valid) { + errors.port = portValidation.error || 'Invalid port'; + } + + // Validate weight + if (!isValidWeight(upstream.weight)) { + errors.weight = 'Weight must be between 1 and 100'; + } + + // Validate maxFails + if (!isValidMaxFails(upstream.maxFails)) { + errors.maxFails = 'Max fails must be between 0 and 100'; + } + + // Validate failTimeout + if (!isValidFailTimeout(upstream.failTimeout)) { + errors.failTimeout = 'Fail timeout must be between 1 and 3600 seconds'; + } + + // Validate maxConns + if (!isValidMaxConns(upstream.maxConns)) { + errors.maxConns = 'Max connections must be between 0 and 100000'; + } + + return { + valid: Object.keys(errors).length === 0, + errors, + }; +} + +/** + * Validate complete NLB configuration + */ +export function validateNLBConfig(config: { + name: string; + port: number; + upstreams: Array<{ + host: string; + port: number; + weight: number; + maxFails: number; + failTimeout: number; + maxConns: number; + backup: boolean; + down: boolean; + }>; + proxyTimeout: number; + proxyConnectTimeout: number; + proxyNextUpstreamTimeout: number; + proxyNextUpstreamTries: number; + healthCheckEnabled: boolean; + healthCheckInterval?: number; + healthCheckTimeout?: number; + healthCheckRises?: number; + healthCheckFalls?: number; +}): { valid: boolean; errors: Record } { + const errors: Record = {}; + + // Validate name + const nameValidation = isValidNLBName(config.name); + if (!nameValidation.valid) { + errors.name = nameValidation.error || 'Invalid name'; + } + + // Validate port + if (!isValidNLBPort(config.port)) { + errors.port = 'Port must be between 10000 and 65535'; + } + + // Validate upstreams + if (!config.upstreams || config.upstreams.length === 0) { + errors.upstreams = 'At least one upstream is required'; + } else { + // Check for duplicate upstreams + const upstreamKeys = new Set(); + const duplicates: string[] = []; + + config.upstreams.forEach((upstream) => { + const key = `${upstream.host}:${upstream.port}`; + if (upstreamKeys.has(key)) { + duplicates.push(key); + } + upstreamKeys.add(key); + }); + + if (duplicates.length > 0) { + errors.upstreams = `Duplicate upstreams detected: ${duplicates.join(', ')}`; + } + + // Check if all upstreams are marked as down or backup + const activeUpstreams = config.upstreams.filter(u => !u.down && !u.backup); + if (activeUpstreams.length === 0) { + errors.upstreams = 'At least one upstream must be active (not marked as down or backup)'; + } + } + + // Validate proxy settings + if (!isValidProxyTimeout(config.proxyTimeout)) { + errors.proxyTimeout = 'Proxy timeout must be between 1 and 3600 seconds'; + } + + if (!isValidProxyConnectTimeout(config.proxyConnectTimeout)) { + errors.proxyConnectTimeout = 'Proxy connect timeout must be between 1 and 300 seconds'; + } + + if (!isValidProxyNextUpstreamTimeout(config.proxyNextUpstreamTimeout)) { + errors.proxyNextUpstreamTimeout = 'Proxy next upstream timeout must be between 0 and 3600 seconds'; + } + + if (!isValidProxyNextUpstreamTries(config.proxyNextUpstreamTries)) { + errors.proxyNextUpstreamTries = 'Proxy next upstream tries must be between 0 and 100'; + } + + // Validate health check settings if enabled + if (config.healthCheckEnabled) { + if (config.healthCheckInterval !== undefined && !isValidHealthCheckInterval(config.healthCheckInterval)) { + errors.healthCheckInterval = 'Health check interval must be between 5 and 3600 seconds'; + } + + if (config.healthCheckTimeout !== undefined && !isValidHealthCheckTimeout(config.healthCheckTimeout)) { + errors.healthCheckTimeout = 'Health check timeout must be between 1 and 300 seconds'; + } + + if (config.healthCheckRises !== undefined && !isValidHealthCheckRises(config.healthCheckRises)) { + errors.healthCheckRises = 'Health check rises must be between 1 and 10'; + } + + if (config.healthCheckFalls !== undefined && !isValidHealthCheckFalls(config.healthCheckFalls)) { + errors.healthCheckFalls = 'Health check falls must be between 1 and 10'; + } + + // Health check timeout must be less than interval + if ( + config.healthCheckTimeout !== undefined && + config.healthCheckInterval !== undefined && + config.healthCheckTimeout >= config.healthCheckInterval + ) { + errors.healthCheckTimeout = 'Health check timeout must be less than interval'; + } + } + + // Proxy connect timeout should be less than proxy timeout + if (config.proxyConnectTimeout >= config.proxyTimeout) { + errors.proxyConnectTimeout = 'Proxy connect timeout should be less than proxy timeout'; + } + + return { + valid: Object.keys(errors).length === 0, + errors, + }; +} + +/** + * Get validation hints for specific fields + */ +export function getValidationHints(field: string): string { + const hints: Record = { + name: 'Use 3-50 characters: letters, numbers, dashes, underscores', + port: 'Port must be between 10000-65535 to avoid conflicts', + host: 'Enter IP address (IPv4/IPv6) or hostname', + upstreamPort: 'Port must be between 1-65535', + weight: 'Weight determines traffic distribution (1-100)', + maxFails: 'Number of failed attempts before marking server down (0-100)', + failTimeout: 'Time to wait before retrying failed server (1-3600s)', + maxConns: 'Maximum concurrent connections (0 = unlimited)', + proxyTimeout: 'Maximum time to wait for upstream response (1-3600s)', + proxyConnectTimeout: 'Maximum time to establish connection (1-300s)', + healthCheckInterval: 'Time between health checks (5-3600s)', + healthCheckTimeout: 'Maximum time to wait for health check response (1-300s)', + }; + + return hints[field] || ''; +} + +/** + * Get example values for fields + */ +export function getExampleValue(field: string): string { + const examples: Record = { + name: 'my-load-balancer', + host: '192.168.1.100 or backend.example.com', + port: '10000', + upstreamPort: '80 or 443', + weight: '1', + maxFails: '3', + failTimeout: '10', + maxConns: '0', + proxyTimeout: '3', + proxyConnectTimeout: '1', + healthCheckInterval: '10', + healthCheckTimeout: '5', + }; + + return examples[field] || ''; +} + +/** + * Check for common configuration issues + */ +export function checkConfigurationWarnings(config: { + upstreams: Array<{ + host: string; + port: number; + weight: number; + maxFails: number; + failTimeout: number; + backup: boolean; + down: boolean; + }>; + proxyTimeout: number; + proxyConnectTimeout: number; + healthCheckEnabled: boolean; + healthCheckInterval?: number; + healthCheckTimeout?: number; +}): string[] { + const warnings: string[] = []; + + // Check if all upstreams have the same weight + const weights = config.upstreams.map(u => u.weight); + if (new Set(weights).size === 1 && weights[0] !== 1) { + warnings.push('All upstreams have the same weight. Consider using weight=1 for simplicity.'); + } + + // Check if proxy timeout is very high + if (config.proxyTimeout > 300) { + warnings.push('Proxy timeout is very high (>5 minutes). This may cause long waits for clients.'); + } + + // Check if health check is disabled + if (!config.healthCheckEnabled) { + warnings.push('Health checks are disabled. Failed upstreams will not be automatically detected.'); + } + + // Check if health check interval is too frequent + if (config.healthCheckEnabled && config.healthCheckInterval && config.healthCheckInterval < 10) { + warnings.push('Health check interval is very frequent (<10s). This may increase server load.'); + } + + // Check if backup servers exist without active servers + const hasBackup = config.upstreams.some(u => u.backup); + const hasActive = config.upstreams.some(u => !u.backup && !u.down); + if (hasBackup && !hasActive) { + warnings.push('Only backup servers configured. They will only be used when all primary servers are down.'); + } + + return warnings; +}