feat: enhance security configuration and SSE management with robust token validation and client tracking
- Refactored `.env.example` with comprehensive security and configuration parameters - Added new `security.config.ts` for centralized security configuration management - Improved middleware with enhanced authentication, request validation, and error handling - Updated SSE routes and manager with advanced client tracking, rate limiting, and connection management - Implemented more granular token validation with IP-based rate limiting and connection tracking - Added detailed error responses and improved logging for security-related events
This commit is contained in:
89
.cursor/tasks
Normal file
89
.cursor/tasks
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
|
||||||
|
### 1. Code Quality & Structure
|
||||||
|
|
||||||
|
- **Refactor Common Logic:**
|
||||||
|
Identify duplicated logic (e.g., in token validations, SSE error handling, and API endpoints) and extract it into shared utility functions or middleware. This will reduce errors and simplify maintenance.
|
||||||
|
|
||||||
|
- **Improve Type Safety:**
|
||||||
|
Leverage TypeScript’s capabilities by refining interfaces and types—especially for API requests/responses and automation configurations. Consider creating more granular type definitions for advanced scenarios.
|
||||||
|
|
||||||
|
- **Modularize and Organize Code:**
|
||||||
|
Ensure that features like device control, automation management, and SSE handling are grouped into well-defined modules. Use dependency injection where possible to decouple component implementations from their usage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. API & Feature Enhancements
|
||||||
|
|
||||||
|
- **Enhanced Authentication & Security:**
|
||||||
|
- Strengthen token validation by adding expiration checks and perhaps support for multiple token schemes (e.g., JWT).
|
||||||
|
- Introduce role-based access controls for varying user privileges.
|
||||||
|
- Implement rate limiting and request throttling to prevent abuse and ensure stability.
|
||||||
|
|
||||||
|
- **SSE & Real-Time Updates:**
|
||||||
|
- Enhance the SSE system to handle reconnection strategies, backoff algorithms, and error recovery automatically.
|
||||||
|
- Consider grouping subscriptions or providing a filtering mechanism on the server side to reduce unnecessary data transfer.
|
||||||
|
|
||||||
|
- **Logging and Monitoring:**
|
||||||
|
- Integrate structured logging (using JSON logs or a dedicated logging library) so that each endpoint, including error responses, contains detailed context.
|
||||||
|
- Link logging with performance metrics to capture slow endpoints, especially for real-time updates and automation configurations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Testing and Documentation
|
||||||
|
|
||||||
|
- **Expanded Test Coverage:**
|
||||||
|
- Write additional unit tests and integration tests, particularly for edge cases in automation rules and error scenarios.
|
||||||
|
- Include tests for correct error handling in SSE, API endpoints, and when processing dynamic parameters.
|
||||||
|
|
||||||
|
- **Live Documentation:**
|
||||||
|
- Enhance the existing README and developer guides with setup instructions and API reference details.
|
||||||
|
- Consider integrating tools (e.g., Swagger or Postman) to auto-generate interactive API documentation for easier onboarding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Performance Optimization
|
||||||
|
|
||||||
|
- **Bun Runtime Optimizations:**
|
||||||
|
- Benchmark performance-critical paths (like SSE and automation processing) to identify latency or memory bottlenecks.
|
||||||
|
- Use caching strategies where possible to reduce repetitive tasks (e.g., caching SSE subscription results or frequently requested device states).
|
||||||
|
|
||||||
|
- **Concurrent Processing:**
|
||||||
|
- Evaluate asynchronous patterns to further optimize I/O operations. Consider using job queues or background workers for tasks that could be decoupled from the main request/response cycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. User Experience and Interface
|
||||||
|
|
||||||
|
- **CLI Enhancements:**
|
||||||
|
- Improve the command-line interface with clearer prompts, improved handling of invalid inputs, and built-in help texts for new users.
|
||||||
|
- Look into adding progress indicators or spinners during long-running operations (e.g., during data collection or AI analysis).
|
||||||
|
|
||||||
|
- **Dashboard or Admin Panel:**
|
||||||
|
- Consider developing a lightweight web dashboard that visualizes device states, automation statuses, and real-time event logs—making it easier for users or admins to monitor the system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. AI Integration Improvements
|
||||||
|
|
||||||
|
- **Refine OpenAI Interactions:**
|
||||||
|
- Enhance error handling and retries when dealing with OpenAI API calls. Provide fallback scenarios (as already partly implemented) and clearer error messages.
|
||||||
|
- Update the prompt templates based on real usage patterns. You might even allow for user-customizable templates for different home automation scenarios.
|
||||||
|
|
||||||
|
- **Contextual Analysis:**
|
||||||
|
- Expand the analysis functionality so that the AI can provide more context-specific recommendations. For instance, analyze and suggest improvements in automation rules, security configurations, or performance optimizations in a more granular way.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Final Polishing and Deployment
|
||||||
|
|
||||||
|
- **Environment Configurations:**
|
||||||
|
- Ensure that environment variables and configuration files (like `.env.example`) are thoroughly documented.
|
||||||
|
- Automate configuration checks and provide clear error logging if critical configuration values (such as tokens or host URLs) are missing.
|
||||||
|
|
||||||
|
- **Deployment Ready:**
|
||||||
|
- Finalize Docker configurations and scripts to enable smooth containerized deployments, perhaps including orchestration hints for multi-instance or load-balanced deployments.
|
||||||
|
- Consider setting up a CI/CD pipeline to run tests, linting, and automated builds with every commit/pull request.
|
||||||
|
|
||||||
|
- **UX/UI Finishing Touches:**
|
||||||
|
- Polish any remaining rough edges in the user interface or command output.
|
||||||
|
- Ensure consistent ANSI coloring/logging outputs and friendly error/warning messages across all user touchpoints (CLI, API, and dashboard).
|
||||||
67
.env.example
67
.env.example
@@ -36,26 +36,50 @@ RATE_LIMIT_REGULAR=100
|
|||||||
# Requests per minute per IP for WebSocket connections
|
# Requests per minute per IP for WebSocket connections
|
||||||
RATE_LIMIT_WEBSOCKET=1000
|
RATE_LIMIT_WEBSOCKET=1000
|
||||||
|
|
||||||
# Security
|
|
||||||
# JWT secret for token generation (change this in production!)
|
|
||||||
JWT_SECRET=your_jwt_secret_key
|
|
||||||
|
|
||||||
# CORS configuration (comma-separated list of allowed origins)
|
|
||||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8123
|
|
||||||
|
|
||||||
# Test Configuration
|
|
||||||
# Only needed if running tests
|
|
||||||
TEST_HASS_HOST=http://localhost:8123
|
|
||||||
TEST_HASS_TOKEN=test_token
|
|
||||||
TEST_HASS_SOCKET_URL=ws://localhost:8123/api/websocket
|
|
||||||
TEST_PORT=3001
|
|
||||||
|
|
||||||
# Security Configuration
|
# Security Configuration
|
||||||
JWT_SECRET=your-secret-key
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your_jwt_secret_key_min_32_chars
|
||||||
|
JWT_EXPIRY=86400000
|
||||||
|
JWT_MAX_AGE=2592000000
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
|
||||||
# Rate Limiting
|
# Rate Limiting
|
||||||
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
|
RATE_LIMIT_WINDOW=900000
|
||||||
RATE_LIMIT_MAX=100
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
|
# Token Security
|
||||||
|
TOKEN_MIN_LENGTH=32
|
||||||
|
MAX_FAILED_ATTEMPTS=5
|
||||||
|
LOCKOUT_DURATION=900000
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ORIGINS=http://localhost:3000,http://localhost:8123
|
||||||
|
CORS_METHODS=GET,POST,PUT,DELETE,OPTIONS
|
||||||
|
CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With
|
||||||
|
CORS_EXPOSED_HEADERS=
|
||||||
|
CORS_CREDENTIALS=true
|
||||||
|
CORS_MAX_AGE=86400
|
||||||
|
|
||||||
|
# Content Security Policy
|
||||||
|
CSP_ENABLED=true
|
||||||
|
CSP_REPORT_ONLY=false
|
||||||
|
CSP_REPORT_URI=
|
||||||
|
|
||||||
|
# SSL/TLS Configuration
|
||||||
|
REQUIRE_HTTPS=true
|
||||||
|
HSTS_MAX_AGE=31536000
|
||||||
|
HSTS_INCLUDE_SUBDOMAINS=true
|
||||||
|
HSTS_PRELOAD=true
|
||||||
|
|
||||||
|
# Cookie Security
|
||||||
|
COOKIE_SECRET=your_cookie_secret_key_min_32_chars
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
COOKIE_HTTP_ONLY=true
|
||||||
|
COOKIE_SAME_SITE=Strict
|
||||||
|
|
||||||
|
# Request Limits
|
||||||
|
MAX_REQUEST_SIZE=1048576
|
||||||
|
MAX_REQUEST_FIELDS=1000
|
||||||
|
|
||||||
# SSE Configuration
|
# SSE Configuration
|
||||||
SSE_MAX_CLIENTS=1000
|
SSE_MAX_CLIENTS=1000
|
||||||
@@ -70,4 +94,11 @@ LOG_COMPRESS=true
|
|||||||
LOG_REQUESTS=true
|
LOG_REQUESTS=true
|
||||||
|
|
||||||
# Version
|
# Version
|
||||||
VERSION=0.1.0
|
VERSION=0.1.0
|
||||||
|
|
||||||
|
# Test Configuration
|
||||||
|
# Only needed if running tests
|
||||||
|
TEST_HASS_HOST=http://localhost:8123
|
||||||
|
TEST_HASS_TOKEN=test_token
|
||||||
|
TEST_HASS_SOCKET_URL=ws://localhost:8123/api/websocket
|
||||||
|
TEST_PORT=3001
|
||||||
@@ -499,6 +499,11 @@ bun run lint:fix
|
|||||||
- 🧪 Test coverage reports
|
- 🧪 Test coverage reports
|
||||||
- 📝 Documentation generation
|
- 📝 Documentation generation
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
This project was initiated by [T T]() and is mainly developed by [Jango Blockchain](https://github.com/jango-blockchained).
|
||||||
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - See [LICENSE](LICENSE) file
|
MIT License - See [LICENSE](LICENSE) file
|
||||||
|
|||||||
112
src/config/security.config.ts
Normal file
112
src/config/security.config.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Security configuration schema
|
||||||
|
const securityConfigSchema = z.object({
|
||||||
|
// JWT Configuration
|
||||||
|
JWT_SECRET: z.string().min(32),
|
||||||
|
JWT_EXPIRY: z.number().default(24 * 60 * 60 * 1000), // 24 hours
|
||||||
|
JWT_MAX_AGE: z.number().default(30 * 24 * 60 * 60 * 1000), // 30 days
|
||||||
|
JWT_ALGORITHM: z.enum(['HS256', 'HS384', 'HS512']).default('HS256'),
|
||||||
|
|
||||||
|
// Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW: z.number().default(15 * 60 * 1000), // 15 minutes
|
||||||
|
RATE_LIMIT_MAX_REQUESTS: z.number().default(100),
|
||||||
|
RATE_LIMIT_WEBSOCKET: z.number().default(1000),
|
||||||
|
|
||||||
|
// Token Security
|
||||||
|
TOKEN_MIN_LENGTH: z.number().default(32),
|
||||||
|
MAX_FAILED_ATTEMPTS: z.number().default(5),
|
||||||
|
LOCKOUT_DURATION: z.number().default(15 * 60 * 1000), // 15 minutes
|
||||||
|
|
||||||
|
// CORS Configuration
|
||||||
|
CORS_ORIGINS: z.array(z.string()).default(['http://localhost:3000', 'http://localhost:8123']),
|
||||||
|
CORS_METHODS: z.array(z.string()).default(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']),
|
||||||
|
CORS_ALLOWED_HEADERS: z.array(z.string()).default([
|
||||||
|
'Content-Type',
|
||||||
|
'Authorization',
|
||||||
|
'X-Requested-With'
|
||||||
|
]),
|
||||||
|
CORS_EXPOSED_HEADERS: z.array(z.string()).default([]),
|
||||||
|
CORS_CREDENTIALS: z.boolean().default(true),
|
||||||
|
CORS_MAX_AGE: z.number().default(24 * 60 * 60), // 24 hours
|
||||||
|
|
||||||
|
// Content Security Policy
|
||||||
|
CSP_ENABLED: z.boolean().default(true),
|
||||||
|
CSP_REPORT_ONLY: z.boolean().default(false),
|
||||||
|
CSP_REPORT_URI: z.string().optional(),
|
||||||
|
|
||||||
|
// SSL/TLS Configuration
|
||||||
|
REQUIRE_HTTPS: z.boolean().default(process.env.NODE_ENV === 'production'),
|
||||||
|
HSTS_MAX_AGE: z.number().default(31536000), // 1 year
|
||||||
|
HSTS_INCLUDE_SUBDOMAINS: z.boolean().default(true),
|
||||||
|
HSTS_PRELOAD: z.boolean().default(true),
|
||||||
|
|
||||||
|
// Cookie Security
|
||||||
|
COOKIE_SECRET: z.string().min(32).optional(),
|
||||||
|
COOKIE_SECURE: z.boolean().default(process.env.NODE_ENV === 'production'),
|
||||||
|
COOKIE_HTTP_ONLY: z.boolean().default(true),
|
||||||
|
COOKIE_SAME_SITE: z.enum(['Strict', 'Lax', 'None']).default('Strict'),
|
||||||
|
|
||||||
|
// Request Limits
|
||||||
|
MAX_REQUEST_SIZE: z.number().default(1024 * 1024), // 1MB
|
||||||
|
MAX_REQUEST_FIELDS: z.number().default(1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse environment variables
|
||||||
|
const parseEnvConfig = () => {
|
||||||
|
const config = {
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET || 'default_secret_key_change_in_production',
|
||||||
|
JWT_EXPIRY: parseInt(process.env.JWT_EXPIRY || '86400000'),
|
||||||
|
JWT_MAX_AGE: parseInt(process.env.JWT_MAX_AGE || '2592000000'),
|
||||||
|
JWT_ALGORITHM: process.env.JWT_ALGORITHM || 'HS256',
|
||||||
|
|
||||||
|
RATE_LIMIT_WINDOW: parseInt(process.env.RATE_LIMIT_WINDOW || '900000'),
|
||||||
|
RATE_LIMIT_MAX_REQUESTS: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100'),
|
||||||
|
RATE_LIMIT_WEBSOCKET: parseInt(process.env.RATE_LIMIT_WEBSOCKET || '1000'),
|
||||||
|
|
||||||
|
TOKEN_MIN_LENGTH: parseInt(process.env.TOKEN_MIN_LENGTH || '32'),
|
||||||
|
MAX_FAILED_ATTEMPTS: parseInt(process.env.MAX_FAILED_ATTEMPTS || '5'),
|
||||||
|
LOCKOUT_DURATION: parseInt(process.env.LOCKOUT_DURATION || '900000'),
|
||||||
|
|
||||||
|
CORS_ORIGINS: (process.env.CORS_ORIGINS || 'http://localhost:3000,http://localhost:8123')
|
||||||
|
.split(',')
|
||||||
|
.map(origin => origin.trim()),
|
||||||
|
CORS_METHODS: (process.env.CORS_METHODS || 'GET,POST,PUT,DELETE,OPTIONS')
|
||||||
|
.split(',')
|
||||||
|
.map(method => method.trim()),
|
||||||
|
CORS_ALLOWED_HEADERS: (process.env.CORS_ALLOWED_HEADERS || 'Content-Type,Authorization,X-Requested-With')
|
||||||
|
.split(',')
|
||||||
|
.map(header => header.trim()),
|
||||||
|
CORS_EXPOSED_HEADERS: (process.env.CORS_EXPOSED_HEADERS || '')
|
||||||
|
.split(',')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(header => header.trim()),
|
||||||
|
CORS_CREDENTIALS: process.env.CORS_CREDENTIALS !== 'false',
|
||||||
|
CORS_MAX_AGE: parseInt(process.env.CORS_MAX_AGE || '86400'),
|
||||||
|
|
||||||
|
CSP_ENABLED: process.env.CSP_ENABLED !== 'false',
|
||||||
|
CSP_REPORT_ONLY: process.env.CSP_REPORT_ONLY === 'true',
|
||||||
|
CSP_REPORT_URI: process.env.CSP_REPORT_URI,
|
||||||
|
|
||||||
|
REQUIRE_HTTPS: process.env.REQUIRE_HTTPS !== 'false' && process.env.NODE_ENV === 'production',
|
||||||
|
HSTS_MAX_AGE: parseInt(process.env.HSTS_MAX_AGE || '31536000'),
|
||||||
|
HSTS_INCLUDE_SUBDOMAINS: process.env.HSTS_INCLUDE_SUBDOMAINS !== 'false',
|
||||||
|
HSTS_PRELOAD: process.env.HSTS_PRELOAD !== 'false',
|
||||||
|
|
||||||
|
COOKIE_SECRET: process.env.COOKIE_SECRET,
|
||||||
|
COOKIE_SECURE: process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production',
|
||||||
|
COOKIE_HTTP_ONLY: process.env.COOKIE_HTTP_ONLY !== 'false',
|
||||||
|
COOKIE_SAME_SITE: (process.env.COOKIE_SAME_SITE || 'Strict') as 'Strict' | 'Lax' | 'None',
|
||||||
|
|
||||||
|
MAX_REQUEST_SIZE: parseInt(process.env.MAX_REQUEST_SIZE || '1048576'),
|
||||||
|
MAX_REQUEST_FIELDS: parseInt(process.env.MAX_REQUEST_FIELDS || '1000'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return securityConfigSchema.parse(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export the validated configuration
|
||||||
|
export const SECURITY_CONFIG = parseEnvConfig();
|
||||||
|
|
||||||
|
// Export types
|
||||||
|
export type SecurityConfig = z.infer<typeof securityConfigSchema>;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { HASS_CONFIG, RATE_LIMIT_CONFIG } from '../config/index.js';
|
import { HASS_CONFIG, RATE_LIMIT_CONFIG } from '../config/index.js';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { TokenManager } from '../security/index.js';
|
||||||
|
|
||||||
// Rate limiter middleware
|
// Rate limiter middleware
|
||||||
export const rateLimiter = rateLimit({
|
export const rateLimiter = rateLimit({
|
||||||
@@ -24,34 +25,79 @@ export const wsRateLimiter = rateLimit({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Security headers middleware
|
// Authentication middleware
|
||||||
export const securityHeaders = (_req: Request, res: Response, next: NextFunction) => {
|
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
const token = req.headers.authorization?.replace('Bearer ', '') || '';
|
||||||
res.setHeader('X-Frame-Options', 'DENY');
|
const clientIp = req.ip || req.socket.remoteAddress || '';
|
||||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
||||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
const validationResult = TokenManager.validateToken(token, clientIp);
|
||||||
|
|
||||||
|
if (!validationResult.valid) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
error: validationResult.error || 'Invalid token',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Request validation middleware
|
// Enhanced security headers middleware
|
||||||
|
export const securityHeaders = (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
// Set strict security headers
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('X-Frame-Options', 'DENY');
|
||||||
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||||
|
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
||||||
|
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' wss: https:;");
|
||||||
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enhanced request validation middleware
|
||||||
export const validateRequest = (req: Request, res: Response, next: NextFunction) => {
|
export const validateRequest = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
// Skip validation for health check endpoints
|
||||||
|
if (req.path === '/health' || req.path === '/mcp') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
// Validate content type for POST/PUT/PATCH requests
|
// Validate content type for POST/PUT/PATCH requests
|
||||||
if (['POST', 'PUT', 'PATCH'].includes(req.method) && !req.is('application/json')) {
|
if (['POST', 'PUT', 'PATCH'].includes(req.method) && !req.is('application/json')) {
|
||||||
return res.status(415).json({
|
return res.status(415).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Content-Type must be application/json'
|
message: 'Unsupported Media Type',
|
||||||
|
error: 'Content-Type must be application/json',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate request body size
|
// Validate request body size
|
||||||
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
||||||
if (contentLength > 1024 * 1024) { // 1MB limit
|
const maxSize = 1024 * 1024; // 1MB limit
|
||||||
|
if (contentLength > maxSize) {
|
||||||
return res.status(413).json({
|
return res.status(413).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Request body too large'
|
message: 'Payload Too Large',
|
||||||
|
error: `Request body must not exceed ${maxSize} bytes`,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate request body structure
|
||||||
|
if (req.method !== 'GET' && req.body) {
|
||||||
|
if (typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Bad Request',
|
||||||
|
error: 'Invalid request body structure',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,21 +130,7 @@ export const sanitizeInput = (req: Request, _res: Response, next: NextFunction)
|
|||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Authentication middleware
|
// Enhanced error handling middleware
|
||||||
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
|
||||||
|
|
||||||
if (!token || token !== HASS_CONFIG.TOKEN) {
|
|
||||||
return res.status(401).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Unauthorized - Invalid token'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Error handling middleware
|
|
||||||
export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
console.error('Error:', err);
|
console.error('Error:', err);
|
||||||
|
|
||||||
@@ -106,8 +138,9 @@ export const errorHandler = (err: Error, _req: Request, res: Response, _next: Ne
|
|||||||
if (err.name === 'ValidationError') {
|
if (err.name === 'ValidationError') {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Validation error',
|
message: 'Validation Error',
|
||||||
details: err.message
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,15 +148,26 @@ export const errorHandler = (err: Error, _req: Request, res: Response, _next: Ne
|
|||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Unauthorized',
|
message: 'Unauthorized',
|
||||||
details: err.message
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.name === 'ForbiddenError') {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Forbidden',
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default error response
|
// Default error response
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Internal server error',
|
message: 'Internal Server Error',
|
||||||
details: process.env.NODE_ENV === 'development' ? err.message : undefined
|
error: process.env.NODE_ENV === 'development' ? err.message : 'An unexpected error occurred',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,28 +2,35 @@ import { Router } from 'express';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { sseManager } from '../sse/index.js';
|
import { sseManager } from '../sse/index.js';
|
||||||
import { TokenManager } from '../security/index.js';
|
import { TokenManager } from '../security/index.js';
|
||||||
|
import { middleware } from '../middleware/index.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// SSE endpoints
|
// SSE endpoints
|
||||||
router.get('/subscribe', (req, res) => {
|
router.get('/subscribe_events', middleware.wsRateLimiter, (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Get token from query parameter
|
// Get token from query parameter and validate
|
||||||
const token = req.query.token?.toString();
|
const token = req.query.token?.toString() || '';
|
||||||
|
const clientIp = req.ip || req.socket.remoteAddress || '';
|
||||||
|
const validationResult = TokenManager.validateToken(token, clientIp);
|
||||||
|
|
||||||
if (!token || !TokenManager.validateToken(token)) {
|
if (!validationResult.valid) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Unauthorized - Invalid token'
|
message: 'Unauthorized',
|
||||||
|
error: validationResult.error,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set SSE headers
|
// Set SSE headers with enhanced security
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
'Connection': 'keep-alive',
|
'Connection': 'keep-alive',
|
||||||
'Access-Control-Allow-Origin': '*'
|
'X-Accel-Buffering': 'no',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Credentials': 'true'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send initial connection message
|
// Send initial connection message
|
||||||
@@ -36,49 +43,51 @@ router.get('/subscribe', (req, res) => {
|
|||||||
const clientId = uuidv4();
|
const clientId = uuidv4();
|
||||||
const client = {
|
const client = {
|
||||||
id: clientId,
|
id: clientId,
|
||||||
|
ip: clientIp,
|
||||||
|
connectedAt: new Date(),
|
||||||
send: (data: string) => {
|
send: (data: string) => {
|
||||||
res.write(`data: ${data}\n\n`);
|
res.write(`data: ${data}\n\n`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add client to SSE manager
|
// Add client to SSE manager with enhanced tracking
|
||||||
const sseClient = sseManager.addClient(client, token);
|
const sseClient = sseManager.addClient(client, token);
|
||||||
if (!sseClient || !sseClient.authenticated) {
|
if (!sseClient || !sseClient.authenticated) {
|
||||||
res.write(`data: ${JSON.stringify({
|
const errorMessage = JSON.stringify({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: sseClient ? 'Authentication failed' : 'Maximum client limit reached',
|
message: sseClient ? 'Authentication failed' : 'Maximum client limit reached',
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
})}\n\n`);
|
});
|
||||||
|
res.write(`data: ${errorMessage}\n\n`);
|
||||||
return res.end();
|
return res.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to events if specified
|
|
||||||
const events = req.query.events?.toString().split(',').filter(Boolean);
|
|
||||||
if (events?.length) {
|
|
||||||
events.forEach(event => sseManager.subscribeToEvent(clientId, event));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to entity if specified
|
|
||||||
const entityId = req.query.entity_id?.toString();
|
|
||||||
if (entityId) {
|
|
||||||
sseManager.subscribeToEntity(clientId, entityId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to domain if specified
|
|
||||||
const domain = req.query.domain?.toString();
|
|
||||||
if (domain) {
|
|
||||||
sseManager.subscribeToDomain(clientId, domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle client disconnect
|
// Handle client disconnect
|
||||||
req.on('close', () => {
|
req.on('close', () => {
|
||||||
sseManager.removeClient(clientId);
|
sseManager.removeClient(clientId);
|
||||||
|
console.log(`Client ${clientId} disconnected at ${new Date().toISOString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
req.on('error', (error) => {
|
||||||
|
console.error(`SSE Error for client ${clientId}:`, error);
|
||||||
|
const errorMessage = JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Connection error',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
res.write(`data: ${errorMessage}\n\n`);
|
||||||
|
sseManager.removeClient(clientId);
|
||||||
|
res.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('SSE Setup Error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
message: 'Internal Server Error',
|
||||||
|
error: error instanceof Error ? error.message : 'An unexpected error occurred',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -96,4 +105,4 @@ router.get('/stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { router as sseRoutes };
|
export default router;
|
||||||
@@ -47,6 +47,18 @@ const ALGORITHM = 'aes-256-gcm';
|
|||||||
const IV_LENGTH = 16;
|
const IV_LENGTH = 16;
|
||||||
const AUTH_TAG_LENGTH = 16;
|
const AUTH_TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
// Security configuration
|
||||||
|
const SECURITY_CONFIG = {
|
||||||
|
TOKEN_EXPIRY: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
MAX_TOKEN_AGE: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||||
|
MIN_TOKEN_LENGTH: 32,
|
||||||
|
MAX_FAILED_ATTEMPTS: 5,
|
||||||
|
LOCKOUT_DURATION: 15 * 60 * 1000, // 15 minutes
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track failed authentication attempts
|
||||||
|
const failedAttempts = new Map<string, { count: number; lastAttempt: number }>();
|
||||||
|
|
||||||
export class TokenManager {
|
export class TokenManager {
|
||||||
/**
|
/**
|
||||||
* Encrypts a token using AES-256-GCM
|
* Encrypts a token using AES-256-GCM
|
||||||
@@ -114,45 +126,123 @@ export class TokenManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a JWT token
|
* Validates a JWT token with enhanced security checks
|
||||||
*/
|
*/
|
||||||
static validateToken(token: string): boolean {
|
static validateToken(token: string, ip?: string): { valid: boolean; error?: string } {
|
||||||
if (!token || typeof token !== 'string') {
|
if (!token || typeof token !== 'string') {
|
||||||
return false;
|
return { valid: false, error: 'Invalid token format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for token length
|
||||||
|
if (token.length < SECURITY_CONFIG.MIN_TOKEN_LENGTH) {
|
||||||
|
return { valid: false, error: 'Token length below minimum requirement' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for rate limiting
|
||||||
|
if (ip) {
|
||||||
|
const attempts = failedAttempts.get(ip);
|
||||||
|
if (attempts) {
|
||||||
|
const timeSinceLastAttempt = Date.now() - attempts.lastAttempt;
|
||||||
|
if (attempts.count >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS) {
|
||||||
|
if (timeSinceLastAttempt < SECURITY_CONFIG.LOCKOUT_DURATION) {
|
||||||
|
return { valid: false, error: 'Too many failed attempts. Please try again later.' };
|
||||||
|
}
|
||||||
|
// Reset after lockout period
|
||||||
|
failedAttempts.delete(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.decode(token);
|
const decoded = jwt.decode(token);
|
||||||
if (!decoded || typeof decoded !== 'object') {
|
if (!decoded || typeof decoded !== 'object') {
|
||||||
return false;
|
this.recordFailedAttempt(ip);
|
||||||
|
return { valid: false, error: 'Invalid token structure' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for expiration
|
// Enhanced expiration checks
|
||||||
if (!decoded.exp) {
|
if (!decoded.exp || !decoded.iat) {
|
||||||
return false;
|
this.recordFailedAttempt(ip);
|
||||||
|
return { valid: false, error: 'Token missing required claims' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (decoded.exp <= now) {
|
if (decoded.exp <= now) {
|
||||||
return false;
|
this.recordFailedAttempt(ip);
|
||||||
|
return { valid: false, error: 'Token has expired' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature using the secret from environment variable
|
// Check token age
|
||||||
|
const tokenAge = (now - decoded.iat) * 1000;
|
||||||
|
if (tokenAge > SECURITY_CONFIG.MAX_TOKEN_AGE) {
|
||||||
|
this.recordFailedAttempt(ip);
|
||||||
|
return { valid: false, error: 'Token exceeds maximum age limit' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
const secret = process.env.JWT_SECRET;
|
const secret = process.env.JWT_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
return false;
|
return { valid: false, error: 'JWT secret not configured' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
jwt.verify(token, secret);
|
jwt.verify(token, secret);
|
||||||
return true;
|
// Reset failed attempts on successful validation
|
||||||
} catch {
|
if (ip) {
|
||||||
return false;
|
failedAttempts.delete(ip);
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
} catch (error) {
|
||||||
|
this.recordFailedAttempt(ip);
|
||||||
|
return { valid: false, error: 'Invalid token signature' };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
return false;
|
this.recordFailedAttempt(ip);
|
||||||
|
return { valid: false, error: 'Token validation failed' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a failed authentication attempt
|
||||||
|
*/
|
||||||
|
private static recordFailedAttempt(ip?: string): void {
|
||||||
|
if (!ip) return;
|
||||||
|
|
||||||
|
const attempts = failedAttempts.get(ip) || { count: 0, lastAttempt: 0 };
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Reset count if last attempt was outside lockout period
|
||||||
|
if (now - attempts.lastAttempt > SECURITY_CONFIG.LOCKOUT_DURATION) {
|
||||||
|
attempts.count = 1;
|
||||||
|
} else {
|
||||||
|
attempts.count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts.lastAttempt = now;
|
||||||
|
failedAttempts.set(ip, attempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new JWT token with enhanced security
|
||||||
|
*/
|
||||||
|
static generateToken(payload: object, expiresIn: number = SECURITY_CONFIG.TOKEN_EXPIRY): string {
|
||||||
|
const secret = process.env.JWT_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('JWT secret not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
secret,
|
||||||
|
{
|
||||||
|
expiresIn: Math.floor(expiresIn / 1000),
|
||||||
|
algorithm: 'HS256'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request validation middleware
|
// Request validation middleware
|
||||||
|
|||||||
264
src/sse/index.ts
264
src/sse/index.ts
@@ -2,6 +2,12 @@ import { EventEmitter } from 'events';
|
|||||||
import { HassEntity, HassEvent } from '../interfaces/hass.js';
|
import { HassEntity, HassEvent } from '../interfaces/hass.js';
|
||||||
import { TokenManager } from '../security/index.js';
|
import { TokenManager } from '../security/index.js';
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const DEFAULT_MAX_CLIENTS = 1000;
|
||||||
|
const DEFAULT_PING_INTERVAL = 30000; // 30 seconds
|
||||||
|
const DEFAULT_CLEANUP_INTERVAL = 60000; // 1 minute
|
||||||
|
const DEFAULT_MAX_CONNECTION_AGE = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
interface RateLimit {
|
interface RateLimit {
|
||||||
count: number;
|
count: number;
|
||||||
lastReset: number;
|
lastReset: number;
|
||||||
@@ -9,63 +15,80 @@ interface RateLimit {
|
|||||||
|
|
||||||
export interface SSEClient {
|
export interface SSEClient {
|
||||||
id: string;
|
id: string;
|
||||||
send: (data: string) => void;
|
ip: string;
|
||||||
subscriptions: {
|
connectedAt: Date;
|
||||||
entities: Set<string>;
|
lastPingAt?: Date;
|
||||||
events: Set<string>;
|
subscriptions: Set<string>;
|
||||||
domains: Set<string>;
|
|
||||||
};
|
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
|
send: (data: string) => void;
|
||||||
rateLimit: RateLimit;
|
rateLimit: RateLimit;
|
||||||
lastPing: number;
|
|
||||||
connectionTime: number;
|
connectionTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ClientStats {
|
||||||
|
id: string;
|
||||||
|
ip: string;
|
||||||
|
connectedAt: Date;
|
||||||
|
lastPingAt?: Date;
|
||||||
|
subscriptionCount: number;
|
||||||
|
connectionDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class SSEManager extends EventEmitter {
|
export class SSEManager extends EventEmitter {
|
||||||
private clients: Map<string, SSEClient> = new Map();
|
private clients: Map<string, SSEClient> = new Map();
|
||||||
private static instance: SSEManager | null = null;
|
private static instance: SSEManager | null = null;
|
||||||
private entityStates: Map<string, HassEntity> = new Map();
|
private entityStates: Map<string, HassEntity> = new Map();
|
||||||
|
private readonly maxClients: number;
|
||||||
|
private readonly pingInterval: number;
|
||||||
|
private readonly cleanupInterval: number;
|
||||||
|
private readonly maxConnectionAge: number;
|
||||||
|
|
||||||
// Configuration
|
constructor(options: {
|
||||||
private readonly MAX_CLIENTS = 100;
|
maxClients?: number;
|
||||||
private readonly RATE_LIMIT_WINDOW = 60000; // 1 minute
|
pingInterval?: number;
|
||||||
private readonly RATE_LIMIT_MAX_REQUESTS = 1000;
|
cleanupInterval?: number;
|
||||||
private readonly CLIENT_TIMEOUT = 300000; // 5 minutes
|
maxConnectionAge?: number;
|
||||||
private readonly PING_INTERVAL = 30000; // 30 seconds
|
} = {}) {
|
||||||
|
|
||||||
private constructor() {
|
|
||||||
super();
|
super();
|
||||||
|
this.maxClients = options.maxClients || DEFAULT_MAX_CLIENTS;
|
||||||
|
this.pingInterval = options.pingInterval || DEFAULT_PING_INTERVAL;
|
||||||
|
this.cleanupInterval = options.cleanupInterval || DEFAULT_CLEANUP_INTERVAL;
|
||||||
|
this.maxConnectionAge = options.maxConnectionAge || DEFAULT_MAX_CONNECTION_AGE;
|
||||||
|
|
||||||
console.log('Initializing SSE Manager...');
|
console.log('Initializing SSE Manager...');
|
||||||
this.startMaintenanceInterval();
|
this.startMaintenanceTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
private startMaintenanceInterval() {
|
private startMaintenanceTasks(): void {
|
||||||
|
// Send periodic pings to keep connections alive
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
this.performMaintenance();
|
this.clients.forEach(client => {
|
||||||
}, 60000); // Run every minute
|
try {
|
||||||
}
|
client.send(JSON.stringify({
|
||||||
|
type: 'ping',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
client.lastPingAt = new Date();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to ping client ${client.id}:`, error);
|
||||||
|
this.removeClient(client.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, this.pingInterval);
|
||||||
|
|
||||||
private performMaintenance() {
|
// Cleanup inactive or expired connections
|
||||||
const now = Date.now();
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
this.clients.forEach((client, clientId) => {
|
||||||
|
const connectionAge = now - client.connectedAt.getTime();
|
||||||
|
const lastPingAge = client.lastPingAt ? now - client.lastPingAt.getTime() : 0;
|
||||||
|
|
||||||
// Check each client for timeouts and rate limits
|
if (connectionAge > this.maxConnectionAge || lastPingAge > this.pingInterval * 2) {
|
||||||
for (const [clientId, client] of this.clients.entries()) {
|
console.log(`Removing inactive client ${clientId}`);
|
||||||
// Remove inactive clients
|
this.removeClient(clientId);
|
||||||
if (now - client.lastPing > this.CLIENT_TIMEOUT) {
|
}
|
||||||
console.log(`Removing inactive client: ${clientId}`);
|
});
|
||||||
this.removeClient(clientId);
|
}, this.cleanupInterval);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset rate limits if window has passed
|
|
||||||
if (now - client.rateLimit.lastReset > this.RATE_LIMIT_WINDOW) {
|
|
||||||
client.rateLimit.count = 0;
|
|
||||||
client.rateLimit.lastReset = now;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log statistics
|
|
||||||
console.log(`Maintenance complete - Active clients: ${this.clients.size}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getInstance(): SSEManager {
|
static getInstance(): SSEManager {
|
||||||
@@ -75,47 +98,32 @@ export class SSEManager extends EventEmitter {
|
|||||||
return SSEManager.instance;
|
return SSEManager.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
addClient(client: { id: string; send: (data: string) => void }, token?: string): SSEClient | null {
|
addClient(client: Omit<SSEClient, 'authenticated' | 'subscriptions'>, token: string): SSEClient | null {
|
||||||
// Check maximum client limit
|
// Validate token
|
||||||
if (this.clients.size >= this.MAX_CLIENTS) {
|
const validationResult = TokenManager.validateToken(token, client.ip);
|
||||||
console.warn('Maximum client limit reached, rejecting new connection');
|
if (!validationResult.valid) {
|
||||||
|
console.warn(`Invalid token for client ${client.id} from IP ${client.ip}: ${validationResult.error}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
// Check client limit
|
||||||
const sseClient: SSEClient = {
|
if (this.clients.size >= this.maxClients) {
|
||||||
id: client.id,
|
console.warn(`Maximum client limit (${this.maxClients}) reached`);
|
||||||
send: client.send,
|
return null;
|
||||||
subscriptions: {
|
}
|
||||||
entities: new Set<string>(),
|
|
||||||
events: new Set<string>(),
|
// Create new client with authentication and subscriptions
|
||||||
domains: new Set<string>()
|
const newClient: SSEClient = {
|
||||||
},
|
...client,
|
||||||
authenticated: this.validateToken(token),
|
authenticated: true,
|
||||||
rateLimit: {
|
subscriptions: new Set(),
|
||||||
count: 0,
|
lastPingAt: new Date()
|
||||||
lastReset: now
|
|
||||||
},
|
|
||||||
lastPing: now,
|
|
||||||
connectionTime: now
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.clients.set(client.id, sseClient);
|
this.clients.set(client.id, newClient);
|
||||||
console.log(`SSE client connected: ${client.id} (authenticated: ${sseClient.authenticated})`);
|
console.log(`New client ${client.id} connected from IP ${client.ip}`);
|
||||||
|
|
||||||
// Start ping interval for this client
|
return newClient;
|
||||||
this.startClientPing(client.id);
|
|
||||||
|
|
||||||
// Send initial connection success message
|
|
||||||
this.sendToClient(sseClient, {
|
|
||||||
type: 'connection',
|
|
||||||
status: 'connected',
|
|
||||||
id: client.id,
|
|
||||||
authenticated: sseClient.authenticated,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
return sseClient;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private startClientPing(clientId: string) {
|
private startClientPing(clientId: string) {
|
||||||
@@ -130,20 +138,24 @@ export class SSEManager extends EventEmitter {
|
|||||||
type: 'ping',
|
type: 'ping',
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}, this.PING_INTERVAL);
|
}, this.pingInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeClient(clientId: string) {
|
removeClient(clientId: string) {
|
||||||
if (this.clients.has(clientId)) {
|
if (this.clients.has(clientId)) {
|
||||||
this.clients.delete(clientId);
|
this.clients.delete(clientId);
|
||||||
console.log(`SSE client disconnected: ${clientId}`);
|
console.log(`SSE client disconnected: ${clientId}`);
|
||||||
|
this.emit('client_disconnected', {
|
||||||
|
clientId,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribeToEntity(clientId: string, entityId: string) {
|
subscribeToEntity(clientId: string, entityId: string) {
|
||||||
const client = this.clients.get(clientId);
|
const client = this.clients.get(clientId);
|
||||||
if (client?.authenticated) {
|
if (client?.authenticated) {
|
||||||
client.subscriptions.entities.add(entityId);
|
client.subscriptions.add(`entity:${entityId}`);
|
||||||
console.log(`Client ${clientId} subscribed to entity: ${entityId}`);
|
console.log(`Client ${clientId} subscribed to entity: ${entityId}`);
|
||||||
|
|
||||||
// Send current state if available
|
// Send current state if available
|
||||||
@@ -166,7 +178,7 @@ export class SSEManager extends EventEmitter {
|
|||||||
subscribeToDomain(clientId: string, domain: string) {
|
subscribeToDomain(clientId: string, domain: string) {
|
||||||
const client = this.clients.get(clientId);
|
const client = this.clients.get(clientId);
|
||||||
if (client?.authenticated) {
|
if (client?.authenticated) {
|
||||||
client.subscriptions.domains.add(domain);
|
client.subscriptions.add(`domain:${domain}`);
|
||||||
console.log(`Client ${clientId} subscribed to domain: ${domain}`);
|
console.log(`Client ${clientId} subscribed to domain: ${domain}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,7 +186,7 @@ export class SSEManager extends EventEmitter {
|
|||||||
subscribeToEvent(clientId: string, eventType: string) {
|
subscribeToEvent(clientId: string, eventType: string) {
|
||||||
const client = this.clients.get(clientId);
|
const client = this.clients.get(clientId);
|
||||||
if (client?.authenticated) {
|
if (client?.authenticated) {
|
||||||
client.subscriptions.events.add(eventType);
|
client.subscriptions.add(`event:${eventType}`);
|
||||||
console.log(`Client ${clientId} subscribed to event: ${eventType}`);
|
console.log(`Client ${clientId} subscribed to event: ${eventType}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,9 +215,9 @@ export class SSEManager extends EventEmitter {
|
|||||||
if (!client.authenticated) continue;
|
if (!client.authenticated) continue;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
client.subscriptions.entities.has(entity.entity_id) ||
|
client.subscriptions.has(`entity:${entity.entity_id}`) ||
|
||||||
client.subscriptions.domains.has(domain) ||
|
client.subscriptions.has(`domain:${domain}`) ||
|
||||||
client.subscriptions.events.has('state_changed')
|
client.subscriptions.has('event:state_changed')
|
||||||
) {
|
) {
|
||||||
this.sendToClient(client, message);
|
this.sendToClient(client, message);
|
||||||
}
|
}
|
||||||
@@ -228,7 +240,7 @@ export class SSEManager extends EventEmitter {
|
|||||||
for (const client of this.clients.values()) {
|
for (const client of this.clients.values()) {
|
||||||
if (!client.authenticated) continue;
|
if (!client.authenticated) continue;
|
||||||
|
|
||||||
if (client.subscriptions.events.has(event.event_type)) {
|
if (client.subscriptions.has(`event:${event.event_type}`)) {
|
||||||
this.sendToClient(client, message);
|
this.sendToClient(client, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,12 +250,12 @@ export class SSEManager extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
// Check rate limit
|
// Check rate limit
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - client.rateLimit.lastReset > this.RATE_LIMIT_WINDOW) {
|
if (now - client.rateLimit.lastReset > this.cleanupInterval) {
|
||||||
client.rateLimit.count = 0;
|
client.rateLimit.count = 0;
|
||||||
client.rateLimit.lastReset = now;
|
client.rateLimit.lastReset = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.rateLimit.count >= this.RATE_LIMIT_MAX_REQUESTS) {
|
if (client.rateLimit.count >= 1000) {
|
||||||
console.warn(`Rate limit exceeded for client ${client.id}`);
|
console.warn(`Rate limit exceeded for client ${client.id}`);
|
||||||
this.sendToClient(client, {
|
this.sendToClient(client, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@@ -255,7 +267,7 @@ export class SSEManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client.rateLimit.count++;
|
client.rateLimit.count++;
|
||||||
client.lastPing = now;
|
client.lastPingAt = new Date();
|
||||||
client.send(JSON.stringify(data));
|
client.send(JSON.stringify(data));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error sending message to client ${client.id}:`, error);
|
console.error(`Error sending message to client ${client.id}:`, error);
|
||||||
@@ -265,7 +277,8 @@ export class SSEManager extends EventEmitter {
|
|||||||
|
|
||||||
private validateToken(token?: string): boolean {
|
private validateToken(token?: string): boolean {
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
return TokenManager.validateToken(token);
|
const validationResult = TokenManager.validateToken(token);
|
||||||
|
return validationResult.valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility methods
|
// Utility methods
|
||||||
@@ -326,63 +339,48 @@ export class SSEManager extends EventEmitter {
|
|||||||
for (const client of this.clients.values()) {
|
for (const client of this.clients.values()) {
|
||||||
if (!client.authenticated) continue;
|
if (!client.authenticated) continue;
|
||||||
|
|
||||||
if (client.subscriptions.events.has(eventType)) {
|
if (client.subscriptions.has(`event:${eventType}`) ||
|
||||||
|
client.subscriptions.has(`entity:${eventType}`) ||
|
||||||
|
client.subscriptions.has(`domain:${eventType.split('.')[0]}`)) {
|
||||||
this.sendToClient(client, message);
|
this.sendToClient(client, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add statistics methods
|
// Add statistics methods
|
||||||
getStatistics() {
|
getStatistics(): {
|
||||||
|
totalClients: number;
|
||||||
|
authenticatedClients: number;
|
||||||
|
clientStats: ClientStats[];
|
||||||
|
subscriptionStats: { [key: string]: number };
|
||||||
|
} {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const stats = {
|
const clientStats: ClientStats[] = [];
|
||||||
total_clients: this.clients.size,
|
const subscriptionCounts: { [key: string]: number } = {};
|
||||||
authenticated_clients: 0,
|
|
||||||
total_subscriptions: 0,
|
|
||||||
clients_by_connection_time: {
|
|
||||||
less_than_1m: 0,
|
|
||||||
less_than_5m: 0,
|
|
||||||
less_than_1h: 0,
|
|
||||||
more_than_1h: 0
|
|
||||||
},
|
|
||||||
total_entities_tracked: this.entityStates.size,
|
|
||||||
subscriptions: {
|
|
||||||
entities: new Set<string>(),
|
|
||||||
events: new Set<string>(),
|
|
||||||
domains: new Set<string>()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const client of this.clients.values()) {
|
this.clients.forEach(client => {
|
||||||
if (client.authenticated) stats.authenticated_clients++;
|
// Collect client statistics
|
||||||
|
clientStats.push({
|
||||||
|
id: client.id,
|
||||||
|
ip: client.ip,
|
||||||
|
connectedAt: client.connectedAt,
|
||||||
|
lastPingAt: client.lastPingAt,
|
||||||
|
subscriptionCount: client.subscriptions.size,
|
||||||
|
connectionDuration: now - client.connectedAt.getTime()
|
||||||
|
});
|
||||||
|
|
||||||
// Count subscriptions
|
// Count subscriptions by type
|
||||||
stats.total_subscriptions +=
|
client.subscriptions.forEach(sub => {
|
||||||
client.subscriptions.entities.size +
|
const [type] = sub.split(':');
|
||||||
client.subscriptions.events.size +
|
subscriptionCounts[type] = (subscriptionCounts[type] || 0) + 1;
|
||||||
client.subscriptions.domains.size;
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Add to subscription sets
|
|
||||||
client.subscriptions.entities.forEach(entity => stats.subscriptions.entities.add(entity));
|
|
||||||
client.subscriptions.events.forEach(event => stats.subscriptions.events.add(event));
|
|
||||||
client.subscriptions.domains.forEach(domain => stats.subscriptions.domains.add(domain));
|
|
||||||
|
|
||||||
// Calculate connection duration
|
|
||||||
const connectionDuration = now - client.connectionTime;
|
|
||||||
if (connectionDuration < 60000) stats.clients_by_connection_time.less_than_1m++;
|
|
||||||
else if (connectionDuration < 300000) stats.clients_by_connection_time.less_than_5m++;
|
|
||||||
else if (connectionDuration < 3600000) stats.clients_by_connection_time.less_than_1h++;
|
|
||||||
else stats.clients_by_connection_time.more_than_1h++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert Sets to Arrays for JSON serialization
|
|
||||||
return {
|
return {
|
||||||
...stats,
|
totalClients: this.clients.size,
|
||||||
subscriptions: {
|
authenticatedClients: Array.from(this.clients.values()).filter(c => c.authenticated).length,
|
||||||
entities: Array.from(stats.subscriptions.entities),
|
clientStats,
|
||||||
events: Array.from(stats.subscriptions.events),
|
subscriptionStats: subscriptionCounts
|
||||||
domains: Array.from(stats.subscriptions.domains)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user