Files
mqtt-explorer/src/server.ts

454 lines
17 KiB
TypeScript

import express, { Request, Response } from 'express'
import * as http from 'http'
import * as path from 'path'
import { Server } from 'socket.io'
import { promises as fsPromise } from 'fs'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import { body, validationResult } from 'express-validator'
import { AuthManager } from './AuthManager'
import { ConnectionManager } from '../backend/src/index'
import ConfigStorage from '../backend/src/ConfigStorage'
import { SocketIOServerEventBus } from '../events/EventSystem/SocketIOServerEventBus'
import { Rpc } from '../events/EventSystem/Rpc'
import { makeOpenDialogRpc, makeSaveDialogRpc } from '../events/OpenDialogRequest'
import { getAppVersion, writeToFile, readFromFile, addMqttConnectionEvent } from '../events'
import { RpcEvents } from '../events/EventsV2'
const PORT = process.env.PORT || 3000
const CREDENTIALS_PATH = path.join(process.cwd(), 'data', 'credentials.json')
const MAX_FILE_SIZE = 16 * 1024 * 1024 // 16MB limit for file uploads
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',') : ['*']
const isProduction = process.env.NODE_ENV === 'production'
// Enable upgrade-insecure-requests only when behind HTTPS reverse proxy
const enableUpgradeInsecure = process.env.UPGRADE_INSECURE_REQUESTS === 'true'
// Enable X-Frame-Options header to prevent iframe embedding (disabled by default)
const enableXFrameOptions = process.env.X_FRAME_OPTIONS === 'true'
/**
* Validates and sanitizes file paths to prevent path traversal attacks
* @param filename The filename to validate
* @returns Sanitized filename or throws error if invalid
*/
function sanitizeFilename(filename: string): string {
if (!filename || typeof filename !== 'string') {
throw new Error('Invalid filename')
}
// Remove any path separators and null bytes
const sanitized = filename.replace(/[/\\]/g, '').replace(/\0/g, '')
// Check for directory traversal patterns
if (sanitized.includes('..') || sanitized.startsWith('.')) {
throw new Error('Invalid filename: directory traversal not allowed')
}
// Ensure filename is not empty after sanitization
if (!sanitized || sanitized.length === 0) {
throw new Error('Invalid filename: empty after sanitization')
}
// Limit filename length
if (sanitized.length > 255) {
throw new Error('Filename too long')
}
return sanitized
}
/**
* Validates that a path is within an allowed directory
* @param targetPath The path to validate
* @param allowedDir The allowed base directory
* @returns True if path is safe, false otherwise
*/
async function isPathSafe(targetPath: string, allowedDir: string): Promise<boolean> {
const fs = await import('fs')
const realTargetPath = await fs.promises.realpath(targetPath).catch(() => targetPath)
const realAllowedDir = await fs.promises.realpath(allowedDir).catch(() => allowedDir)
return realTargetPath.startsWith(realAllowedDir)
}
async function startServer() {
// Initialize authentication
const authManager = new AuthManager(CREDENTIALS_PATH)
await authManager.initialize()
// Create Express app
const app = express()
// Apply security headers with helmet
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // unsafe-eval required for webpack runtime
styleSrc: ["'self'", "'unsafe-inline'"], // Required for Material-UI
connectSrc: ["'self'", 'ws:', 'wss:'], // Allow WebSocket connections
imgSrc: ["'self'", 'data:', 'blob:'],
upgradeInsecureRequests: enableUpgradeInsecure ? [] : null, // Only enable when behind HTTPS reverse proxy
},
},
hsts: isProduction
? {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
}
: false,
frameguard: enableXFrameOptions ? { action: 'sameorigin' } : false, // Disabled by default to allow iframe embedding
// Disable cross-origin policies that cause blank pages when accessing via IP vs localhost
// These headers can block resources and cause rendering issues on HTTP-only deployments
crossOriginEmbedderPolicy: false, // Can block resources without proper CORP headers
crossOriginOpenerPolicy: false, // Can cause blank pages and window isolation issues
crossOriginResourcePolicy: false, // Can block cross-origin resource loading
originAgentCluster: false, // Causes issues when switching between localhost and IP address origins
})
)
// Rate limiting for authentication attempts
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 requests per windowMs
message: 'Too many authentication attempts, please try again later',
standardHeaders: true,
legacyHeaders: false,
})
const server = http.createServer(app)
// Determine allowed origins for CORS
const corsOrigin =
ALLOWED_ORIGINS[0] === '*' && isProduction
? false // In production, require explicit origins
: ALLOWED_ORIGINS[0] === '*'
? '*'
: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
if (!origin || ALLOWED_ORIGINS.includes(origin)) {
callback(null, true)
} else {
callback(new Error('Not allowed by CORS'))
}
}
const io = new Server(server, {
cors: {
origin: corsOrigin,
methods: ['GET', 'POST'],
credentials: true,
},
allowEIO3: true, // Allow Engine.IO v3 clients (backwards compatibility)
transports: ['websocket', 'polling'], // Support both transports
pingTimeout: 60000, // Increase ping timeout
pingInterval: 25000, // Ping interval
maxHttpBufferSize: MAX_FILE_SIZE, // Limit message size
})
// Track failed authentication attempts per IP with exponential back-off
const failedAttempts = new Map<string, { count: number; lastAttempt: number }>()
/**
* Calculate exponential back-off wait time based on failed attempts
* @param attemptCount Number of failed attempts
* @returns Wait time in milliseconds
*/
function calculateBackoffTime(attemptCount: number): number {
// Progressive back-off with longer delays
// Attempt 1: 5 seconds
// Attempt 2: 10 seconds
// Attempt 3: 30 seconds
// Attempt 4: 60 seconds (1 minute)
// Attempt 5: 120 seconds (2 minutes)
// Attempt 6: 300 seconds (5 minutes)
// Attempt 7+: 900 seconds (15 minutes, capped)
const backoffSequence = [5, 10, 30, 60, 120, 300, 900]
const index = Math.min(attemptCount - 1, backoffSequence.length - 1)
return backoffSequence[index] * 1000
}
// Authentication middleware for Socket.io
io.use(async (socket, next) => {
// Skip authentication if disabled
if (authManager.isAuthDisabled()) {
if (!isProduction) {
console.log('Client connected without authentication (auth disabled)')
}
// Mark socket as auth-disabled for later identification
;(socket as any).authDisabled = true
return next()
}
const { username, password } = socket.handshake.auth
const clientIp = socket.handshake.address
// Check rate limiting per IP
const now = Date.now()
const attempts = failedAttempts.get(clientIp) || { count: 0, lastAttempt: 0 }
// Calculate back-off time based on previous failed attempts
if (attempts.count > 0) {
const backoffTime = calculateBackoffTime(attempts.count)
const timeSinceLastAttempt = now - attempts.lastAttempt
const remainingWaitTime = backoffTime - timeSinceLastAttempt
if (remainingWaitTime > 0) {
const secondsRemaining = Math.ceil(remainingWaitTime / 1000)
return next(new Error(`Too many failed authentication attempts. Please wait ${secondsRemaining} seconds before trying again.`))
}
}
if (!username || !password) {
attempts.count++
attempts.lastAttempt = now
failedAttempts.set(clientIp, attempts)
return next(new Error('Authentication required'))
}
const isValid = await authManager.verifyCredentials(username, password)
if (!isValid) {
attempts.count++
attempts.lastAttempt = now
failedAttempts.set(clientIp, attempts)
// Calculate next wait time for informational purposes
const nextBackoff = calculateBackoffTime(attempts.count)
const nextWaitSeconds = Math.ceil(nextBackoff / 1000)
return next(new Error(`Invalid credentials. Next attempt allowed in ${nextWaitSeconds} seconds.`))
}
// Reset failed attempts on successful auth
failedAttempts.delete(clientIp)
if (!isProduction) {
console.log('Client authenticated:', username)
}
next()
})
// Initialize backend event bus with Socket.io
const backendEvents = new SocketIOServerEventBus(io)
const backendRpc = new Rpc(backendEvents)
// Initialize connection manager
const connectionManager = new ConnectionManager(backendEvents)
connectionManager.manageConnections()
// Initialize config storage
const configStorage = new ConfigStorage(path.join(process.cwd(), 'data', 'settings.json'), backendRpc)
configStorage.init()
// Send auth status to clients on connection
io.on('connection', (socket) => {
// Inform client about auth status
const authDisabled = (socket as any).authDisabled === true
socket.emit('auth-status', { authDisabled })
if (!isProduction) {
console.log(`Client connected, auth disabled: ${authDisabled}`)
}
// Auto-connect to MQTT broker if configured via environment variables
const autoConnectHost = process.env.MQTT_AUTO_CONNECT_HOST
if (autoConnectHost) {
const connectionId = 'auto-connect-' + Date.now()
// Notify client immediately that auto-connect will happen
socket.emit('auto-connect-initiated', { connectionId })
// Delay auto-connect to give client time to subscribe to events
setTimeout(() => {
const protocol = process.env.MQTT_AUTO_CONNECT_PROTOCOL || 'mqtt'
const port = parseInt(process.env.MQTT_AUTO_CONNECT_PORT || '1883')
const tls = protocol.endsWith('s') // mqtts or wss
const url = `${protocol}://${autoConnectHost}:${port}`
const autoConnectConfig = {
id: connectionId,
options: {
url,
username: process.env.MQTT_AUTO_CONNECT_USERNAME,
password: process.env.MQTT_AUTO_CONNECT_PASSWORD,
tls,
certValidation: false,
clientId: process.env.MQTT_AUTO_CONNECT_CLIENT_ID || 'mqtt-explorer-' + Math.random().toString(16).substr(2, 8),
subscriptions: [{ topic: '#', qos: 0 as 0 | 1 | 2 }], // Subscribe to all topics
}
}
if (!isProduction) {
console.log('Auto-connecting to MQTT broker:', {
connectionId,
url: autoConnectConfig.options.url,
clientId: autoConnectConfig.options.clientId,
username: autoConnectConfig.options.username || '(none)',
})
}
// Trigger connection via backend events
backendEvents.emit(addMqttConnectionEvent, autoConnectConfig)
}, 1000) // 1 second delay to allow client to set up event subscriptions
}
})
// Setup RPC handlers for file operations
backendRpc.on(makeOpenDialogRpc(), async request => {
// In browser mode, file selection is handled client-side via upload
// Return empty result as this will be handled differently
return { canceled: true, filePaths: [] }
})
backendRpc.on(makeSaveDialogRpc(), async request => {
// In browser mode, file saving is handled client-side via download
return { canceled: true, filePath: '' }
})
backendRpc.on(getAppVersion, async () => {
// Return version from package.json
try {
const packageJsonPath = path.join(__dirname, '..', '..', 'package.json')
const packageJsonData = await fsPromise.readFile(packageJsonPath, 'utf8')
const packageJson = JSON.parse(packageJsonData)
return packageJson.version
} catch (e) {
return '0.0.0'
}
})
backendRpc.on(writeToFile, async ({ filePath, data, encoding }) => {
// In browser mode, we store files in the server's data directory
const dataDir = path.join(process.cwd(), 'data', 'uploads')
try {
// Validate filename to prevent path traversal
const sanitizedFilename = sanitizeFilename(path.basename(filePath))
const safePath = path.join(dataDir, sanitizedFilename)
// Ensure data directory exists
await fsPromise.mkdir(dataDir, { recursive: true })
// Verify the final path is within the allowed directory
if (!(await isPathSafe(safePath, dataDir))) {
throw new Error('Invalid file path')
}
// Validate data size
const dataBuffer = Buffer.from(data, 'base64')
if (dataBuffer.length > MAX_FILE_SIZE) {
throw new Error(`File size exceeds maximum allowed size of ${MAX_FILE_SIZE} bytes`)
}
// Write file
if (encoding) {
await fsPromise.writeFile(safePath, dataBuffer, { encoding: encoding as BufferEncoding })
} else {
await fsPromise.writeFile(safePath, dataBuffer)
}
} catch (error) {
console.error('Error writing file:', error instanceof Error ? error.message : 'Unknown error')
throw new Error('Failed to write file')
}
})
backendRpc.on(readFromFile, async ({ filePath, encoding }) => {
// In browser mode, files are read from the server's data directory
const dataDir = path.join(process.cwd(), 'data', 'uploads')
try {
// Validate filename to prevent path traversal
const sanitizedFilename = sanitizeFilename(path.basename(filePath))
const safePath = path.join(dataDir, sanitizedFilename)
// Verify the final path is within the allowed directory
if (!(await isPathSafe(safePath, dataDir))) {
throw new Error('Invalid file path')
}
// Read file
if (encoding) {
const content = await fsPromise.readFile(safePath, { encoding: encoding as BufferEncoding })
return Buffer.from(content)
}
return await fsPromise.readFile(safePath)
} catch (error) {
console.error('Error reading file:', error instanceof Error ? error.message : 'Unknown error')
throw new Error('Failed to read file')
}
})
// Certificate upload handler - via IPC for consistency
backendRpc.on(RpcEvents.uploadCertificate, async ({ filename, data }) => {
try {
// Validate filename to prevent path traversal
const sanitizedFilename = sanitizeFilename(filename)
// Validate data size
const dataBuffer = Buffer.from(data, 'base64')
if (dataBuffer.length > MAX_FILE_SIZE) {
throw new Error(`Certificate size exceeds maximum allowed size of ${MAX_FILE_SIZE} bytes`)
}
// Store certificate on server for browser mode
const dataDir = path.join(process.cwd(), 'data', 'certificates')
await fsPromise.mkdir(dataDir, { recursive: true })
const safePath = path.join(dataDir, sanitizedFilename)
// Verify the final path is within the allowed directory
if (!(await isPathSafe(safePath, dataDir))) {
throw new Error('Invalid certificate path')
}
await fsPromise.writeFile(safePath, dataBuffer)
if (!isProduction) {
console.log('Certificate uploaded:', sanitizedFilename)
}
// Return the certificate data for client to use
return {
name: sanitizedFilename,
data,
}
} catch (error) {
console.error('Error uploading certificate:', error instanceof Error ? error.message : 'Unknown error')
throw new Error('Failed to upload certificate')
}
})
// Serve static files
app.use(express.static(path.join(__dirname, '..', '..', 'app', 'build')))
// Serve index.html for all other routes (SPA)
app.use((req: Request, res: Response) => {
res.sendFile(path.join(__dirname, '..', '..', 'app', 'index.html'))
})
// Start server
server.listen(PORT, () => {
console.log('='.repeat(60))
console.log(`MQTT Explorer server running on http://localhost:${PORT}`)
console.log('='.repeat(60))
})
// Handle graceful shutdown
process.on('SIGTERM' as any, () => {
console.log('SIGTERM received, closing connections...')
connectionManager.closeAllConnections()
server.close()
})
process.on('SIGINT' as any, () => {
console.log('SIGINT received, closing connections...')
connectionManager.closeAllConnections()
server.close()
process.exit(0)
})
}
startServer().catch(error => {
console.error('Failed to start server:', error)
process.exit(1)
})