diff --git a/docker/nginx.conf b/docker/nginx.conf index 2a943a46..5d939c23 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -72,25 +72,37 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + # WebSocket proxy for authenticated terminal connections location /ssh/websocket/ { + # Pass to WebSocket server with authentication support proxy_pass http://127.0.0.1:8082/; proxy_http_version 1.1; + + # WebSocket upgrade headers proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "Upgrade"; + proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; - proxy_read_timeout 300s; - proxy_send_timeout 300s; - proxy_connect_timeout 75s; - proxy_set_header Connection ""; - - proxy_buffering off; - proxy_request_buffering off; - + # Pass client information for authentication logging proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # Important: Pass query parameters (contains JWT token) + proxy_pass_request_args on; + + # WebSocket timeouts (longer for terminal sessions) + proxy_read_timeout 86400s; # 24 hours + proxy_send_timeout 86400s; # 24 hours + proxy_connect_timeout 10s; # Quick auth check + + # Disable buffering for real-time terminal + proxy_buffering off; + proxy_request_buffering off; + + # Handle connection errors gracefully + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; } location /ssh/tunnel/ { diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 6167a8ad..0bbef852 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -20,7 +20,16 @@ import { UserDataImport } from "../utils/user-data-import.js"; const app = express(); app.use( cors({ - origin: "*", + // SECURITY: Specific origins only - no wildcard for production safety + origin: process.env.ALLOWED_ORIGINS ? + process.env.ALLOWED_ORIGINS.split(',').map(origin => origin.trim()) : + [ + "http://localhost:3000", // Development React + "http://localhost:5173", // Development Vite + "http://127.0.0.1:3000", // Local development + "http://127.0.0.1:5173", // Local development + ], + credentials: true, // Enable credentials for secure cookies/auth methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: [ "Content-Type", diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 8fa1ea5a..98e3b498 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -1,34 +1,195 @@ import { WebSocketServer, WebSocket, type RawData } from "ws"; import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2"; +import { parse as parseUrl } from "url"; import { db } from "../database/db/index.js"; import { sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { sshLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; +import { AuthManager } from "../utils/auth-manager.js"; +import { UserCrypto } from "../utils/user-crypto.js"; -const wss = new WebSocketServer({ port: 8082 }); +// Get auth instances +const authManager = AuthManager.getInstance(); +const userCrypto = UserCrypto.getInstance(); -sshLogger.success("SSH Terminal WebSocket server started", { - operation: "server_start", +// Track user connections for rate limiting +const userConnections = new Map>(); + +const wss = new WebSocketServer({ port: 8082, + // WebSocket authentication during handshake + verifyClient: async (info) => { + try { + const url = parseUrl(info.req.url!, true); + const token = url.query.token as string; + + if (!token) { + sshLogger.warn("WebSocket connection rejected: missing token", { + operation: "websocket_auth_reject", + reason: "missing_token", + origin: info.origin, + ip: info.req.socket.remoteAddress + }); + return false; + } + + // Verify JWT token + const payload = await authManager.verifyJWTToken(token); + if (!payload) { + sshLogger.warn("WebSocket connection rejected: invalid token", { + operation: "websocket_auth_reject", + reason: "invalid_token", + origin: info.origin, + ip: info.req.socket.remoteAddress + }); + return false; + } + + // Check for TOTP pending (should not allow terminal access during TOTP) + if (payload.pendingTOTP) { + sshLogger.warn("WebSocket connection rejected: TOTP verification pending", { + operation: "websocket_auth_reject", + reason: "totp_pending", + userId: payload.userId, + ip: info.req.socket.remoteAddress + }); + return false; + } + + // Check connection limits per user (max 3 concurrent connections) + const existingConnections = userConnections.get(payload.userId); + if (existingConnections && existingConnections.size >= 3) { + sshLogger.warn("WebSocket connection rejected: too many connections", { + operation: "websocket_auth_reject", + reason: "connection_limit", + userId: payload.userId, + currentConnections: existingConnections.size, + ip: info.req.socket.remoteAddress + }); + return false; + } + + // Attach user info to request object + (info.req as any).userId = payload.userId; + (info.req as any).userPayload = payload; + + sshLogger.info("WebSocket connection authenticated", { + operation: "websocket_auth_success", + userId: payload.userId, + ip: info.req.socket.remoteAddress + }); + + return true; + } catch (error) { + sshLogger.error("WebSocket authentication error", error, { + operation: "websocket_auth_error", + ip: info.req.socket.remoteAddress + }); + return false; + } + } }); -wss.on("connection", (ws: WebSocket) => { +sshLogger.success("SSH Terminal WebSocket server started with authentication", { + operation: "server_start", + port: 8082, + features: ["JWT_auth", "connection_limits", "data_access_control"] +}); + +wss.on("connection", (ws: WebSocket, req) => { + // Extract authenticated user info from request + const userId = (req as any).userId; + const userPayload = (req as any).userPayload; + + if (!userId) { + sshLogger.error("WebSocket connection without authentication - should not happen", { + operation: "websocket_security_violation", + ip: req.socket.remoteAddress + }); + ws.close(1008, "Authentication required"); + return; + } + + // Check data access permissions + const dataKey = userCrypto.getUserDataKey(userId); + if (!dataKey) { + sshLogger.warn("WebSocket connection rejected: data locked", { + operation: "websocket_data_locked", + userId, + ip: req.socket.remoteAddress + }); + ws.send(JSON.stringify({ + type: "error", + message: "Data locked - re-authenticate with password", + code: "DATA_LOCKED" + })); + ws.close(1008, "Data access required"); + return; + } + + // Track user connections for limits + if (!userConnections.has(userId)) { + userConnections.set(userId, new Set()); + } + const userWs = userConnections.get(userId)!; + userWs.add(ws); + + sshLogger.info("WebSocket connection established", { + operation: "websocket_connection_established", + userId, + userConnections: userWs.size, + ip: req.socket.remoteAddress + }); + let sshConn: Client | null = null; let sshStream: ClientChannel | null = null; let pingInterval: NodeJS.Timeout | null = null; ws.on("close", () => { + // Clean up user connection tracking + const userWs = userConnections.get(userId); + if (userWs) { + userWs.delete(ws); + if (userWs.size === 0) { + userConnections.delete(userId); + } + } + + sshLogger.info("WebSocket connection closed", { + operation: "websocket_connection_closed", + userId, + remainingConnections: userWs?.size || 0 + }); + cleanupSSH(); }); ws.on("message", (msg: RawData) => { + // Verify user still has data access before processing any messages + const currentDataKey = userCrypto.getUserDataKey(userId); + if (!currentDataKey) { + sshLogger.warn("WebSocket message rejected: data access expired", { + operation: "websocket_message_rejected", + userId, + reason: "data_access_expired" + }); + ws.send(JSON.stringify({ + type: "error", + message: "Data access expired - please re-authenticate", + code: "DATA_EXPIRED" + })); + ws.close(1008, "Data access expired"); + return; + } + let parsed: any; try { parsed = JSON.parse(msg.toString()); } catch (e) { sshLogger.error("Invalid JSON received", e, { - operation: "websocket_message", + operation: "websocket_message_invalid_json", + userId, messageLength: msg.toString().length, }); ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })); @@ -39,9 +200,14 @@ wss.on("connection", (ws: WebSocket) => { switch (type) { case "connectToHost": + // Ensure userId is attached to hostConfig for secure credential resolution + if (data.hostConfig) { + data.hostConfig.userId = userId; + } handleConnectToHost(data).catch((error) => { sshLogger.error("Failed to connect to host", error, { operation: "ssh_connect", + userId, hostId: data.hostConfig?.id, ip: data.hostConfig?.ip, }); @@ -82,7 +248,8 @@ wss.on("connection", (ws: WebSocket) => { default: sshLogger.warn("Unknown message type received", { - operation: "websocket_message", + operation: "websocket_message_unknown_type", + userId, messageType: type, }); } @@ -187,15 +354,15 @@ wss.on("connection", (ws: WebSocket) => { hasCredentialId: !!credentialId, }); - if (password) { - sshLogger.debug(`Password preview: "${password.substring(0, 15)}..."`, { - operation: "terminal_ssh_password", - }); - } else { - sshLogger.debug(`No password provided`, { - operation: "terminal_ssh_password", - }); - } + // SECURITY: Never log password information - removed password preview logging + sshLogger.debug(`SSH authentication setup`, { + operation: "terminal_ssh_auth_setup", + userId, + hostId: id, + authType, + hasPassword: !!password, + hasCredentialId: !!credentialId, + }); let resolvedCredentials = { password, key, keyPassword, keyType, authType }; if (credentialId && id && hostConfig.userId) { diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index 24658710..1030b541 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -1,9 +1,4 @@ import crypto from "crypto"; -import path from "path"; -import { promises as fs } from "fs"; -import { db } from "../database/db/index.js"; -import { settings } from "../database/db/schema.js"; -import { eq } from "drizzle-orm"; import { databaseLogger } from "./logger.js"; /** @@ -20,11 +15,6 @@ class SystemCrypto { private jwtSecret: string | null = null; private databaseKey: Buffer | null = null; - // Storage path configuration - private static readonly JWT_SECRET_FILE = path.join(process.cwd(), '.termix', 'jwt.key'); - private static readonly JWT_SECRET_DB_KEY = 'system_jwt_secret'; - private static readonly DATABASE_KEY_FILE = path.join(process.cwd(), '.termix', 'db.key'); - private static readonly DATABASE_KEY_DB_KEY = 'system_database_key'; private constructor() {} @@ -36,7 +26,7 @@ class SystemCrypto { } /** - * Initialize JWT secret - open source friendly way + * Initialize JWT secret - environment variable only */ async initializeJWTSecret(): Promise { try { @@ -44,7 +34,7 @@ class SystemCrypto { operation: "jwt_init", }); - // 1. Environment variable priority (production best practice) + // Check environment variable const envSecret = process.env.JWT_SECRET; if (envSecret && envSecret.length >= 64) { this.jwtSecret = envSecret; @@ -55,30 +45,8 @@ class SystemCrypto { return; } - // 2. Check filesystem storage - const fileSecret = await this.loadSecretFromFile(); - if (fileSecret) { - this.jwtSecret = fileSecret; - databaseLogger.info("✅ Loaded JWT secret from file", { - operation: "jwt_file_loaded", - source: "file" - }); - return; - } - - // 3. Check database storage - const dbSecret = await this.loadSecretFromDB(); - if (dbSecret) { - this.jwtSecret = dbSecret; - databaseLogger.info("✅ Loaded JWT secret from database", { - operation: "jwt_db_loaded", - source: "database" - }); - return; - } - - // 4. Generate new key and persist - await this.generateAndStoreSecret(); + // No environment variable - generate and guide user + await this.generateAndGuideUser(); } catch (error) { databaseLogger.error("Failed to initialize JWT secret", error, { @@ -99,7 +67,7 @@ class SystemCrypto { } /** - * Initialize database encryption key - same pattern as JWT but for database file encryption + * Initialize database encryption key - environment variable only */ async initializeDatabaseKey(): Promise { try { @@ -107,7 +75,7 @@ class SystemCrypto { operation: "db_key_init", }); - // 1. Environment variable priority (production best practice) + // Check environment variable const envKey = process.env.DATABASE_KEY; if (envKey && envKey.length >= 64) { this.databaseKey = Buffer.from(envKey, 'hex'); @@ -118,19 +86,8 @@ class SystemCrypto { return; } - // 2. Check filesystem storage - const fileKey = await this.loadDatabaseKeyFromFile(); - if (fileKey) { - this.databaseKey = fileKey; - databaseLogger.info("✅ Loaded database key from file", { - operation: "db_key_file_loaded", - source: "file" - }); - return; - } - - // 3. Generate new key and persist (NO database storage to avoid circular dependency) - await this.generateAndStoreDatabaseKey(); + // No environment variable - generate and guide user + await this.generateAndGuideDatabaseKey(); } catch (error) { databaseLogger.error("Failed to initialize database key", error, { @@ -151,234 +108,71 @@ class SystemCrypto { } /** - * Generate new key and persist storage + * Generate and guide user - no fallback storage */ - private async generateAndStoreSecret(): Promise { + private async generateAndGuideUser(): Promise { const newSecret = crypto.randomBytes(32).toString('hex'); const instanceId = crypto.randomBytes(8).toString('hex'); - databaseLogger.info("🔑 Generating new JWT secret for this Termix instance", { - operation: "jwt_generate", - instanceId - }); - - // Try file storage (priority, faster and doesn't depend on database) - try { - await this.saveSecretToFile(newSecret); - databaseLogger.info("✅ JWT secret saved to file", { - operation: "jwt_file_saved", - path: SystemCrypto.JWT_SECRET_FILE - }); - } catch (fileError) { - databaseLogger.warn("⚠️ Cannot save to file, using database storage", { - operation: "jwt_file_save_failed", - error: fileError instanceof Error ? fileError.message : "Unknown error" - }); - - // File storage failed, use database - await this.saveSecretToDB(newSecret, instanceId); - databaseLogger.info("✅ JWT secret saved to database", { - operation: "jwt_db_saved" - }); - } - + // Set in memory for current session this.jwtSecret = newSecret; - databaseLogger.success("🔐 This Termix instance now has a unique JWT secret", { - operation: "jwt_generated_success", + // Guide user to set environment variable + console.log("\n" + "=".repeat(80)); + console.log("🔐 TERMIX FIRST STARTUP - JWT SECRET REQUIRED"); + console.log("=".repeat(80)); + console.log(`Generated JWT Secret: ${newSecret}`); + console.log(""); + console.log("⚠️ REQUIRED: Set this environment variable:"); + console.log(` export JWT_SECRET=${newSecret}`); + console.log(""); + console.log("🔄 Restart Termix after setting the environment variable"); + console.log("=".repeat(80) + "\n"); + + databaseLogger.warn("⚠️ JWT secret generated for current session only", { + operation: "jwt_temp_generated", instanceId, - note: "All tokens from previous sessions are invalidated" + envVarName: "JWT_SECRET", + note: "Set environment variable and restart for persistent operation" }); } - // ===== File storage methods ===== - - /** - * Save key to file - */ - private async saveSecretToFile(secret: string): Promise { - const dir = path.dirname(SystemCrypto.JWT_SECRET_FILE); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(SystemCrypto.JWT_SECRET_FILE, secret, { - mode: 0o600 // Only owner can read/write - }); - } - - /** - * Load key from file - */ - private async loadSecretFromFile(): Promise { - try { - const secret = await fs.readFile(SystemCrypto.JWT_SECRET_FILE, 'utf8'); - if (secret.trim().length >= 64) { - return secret.trim(); - } - databaseLogger.warn("JWT secret file exists but too short", { - operation: "jwt_file_invalid", - length: secret.length - }); - } catch (error) { - // File doesn't exist or can't be read, this is normal - } - return null; - } // ===== Database key generation and storage methods ===== /** - * Generate new database key and persist to file storage only - * (avoid circular dependency with database) + * Generate and guide database key - no fallback storage */ - private async generateAndStoreDatabaseKey(): Promise { + private async generateAndGuideDatabaseKey(): Promise { const newKey = crypto.randomBytes(32); // 256-bit key for AES-256 + const newKeyHex = newKey.toString('hex'); const instanceId = crypto.randomBytes(8).toString('hex'); - databaseLogger.info("🔑 Generating new database encryption key for this Termix instance", { - operation: "db_key_generate", - instanceId - }); - - // Only try file storage (no database storage to avoid circular dependency) - try { - await this.saveDatabaseKeyToFile(newKey); - databaseLogger.info("✅ Database key saved to file", { - operation: "db_key_file_saved", - path: SystemCrypto.DATABASE_KEY_FILE - }); - } catch (fileError) { - databaseLogger.error("❌ Failed to save database key to file", { - operation: "db_key_file_save_failed", - error: fileError instanceof Error ? fileError.message : "Unknown error", - note: "Database encryption cannot work without persistent key storage" - }); - throw new Error("Database key file storage is required for database encryption"); - } - + // Set in memory for current session this.databaseKey = newKey; - databaseLogger.success("🔐 This Termix instance now has a unique database encryption key", { - operation: "db_key_generated_success", + // Guide user to set environment variable + console.log("\n" + "=".repeat(80)); + console.log("🔒 TERMIX FIRST STARTUP - DATABASE KEY REQUIRED"); + console.log("=".repeat(80)); + console.log(`Generated Database Key: ${newKeyHex}`); + console.log(""); + console.log("⚠️ REQUIRED: Set this environment variable:"); + console.log(` export DATABASE_KEY=${newKeyHex}`); + console.log(""); + console.log("🔄 Restart Termix after setting the environment variable"); + console.log("=".repeat(80) + "\n"); + + databaseLogger.warn("⚠️ Database key generated for current session only", { + operation: "db_key_temp_generated", instanceId, - note: "Database file is now encrypted at rest" + envVarName: "DATABASE_KEY", + note: "Set environment variable and restart for persistent operation" }); } - /** - * Save database key to file (binary format) - */ - private async saveDatabaseKeyToFile(key: Buffer): Promise { - const dir = path.dirname(SystemCrypto.DATABASE_KEY_FILE); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(SystemCrypto.DATABASE_KEY_FILE, key.toString('hex'), { - mode: 0o600 // Only owner can read/write - }); - } - /** - * Load database key from file - */ - private async loadDatabaseKeyFromFile(): Promise { - try { - const keyHex = await fs.readFile(SystemCrypto.DATABASE_KEY_FILE, 'utf8'); - if (keyHex.trim().length >= 64) { // 32 bytes = 64 hex chars - return Buffer.from(keyHex.trim(), 'hex'); - } - databaseLogger.warn("Database key file exists but too short", { - operation: "db_key_file_invalid", - length: keyHex.length - }); - } catch (error) { - // File doesn't exist or can't be read, this is normal - } - return null; - } - // ===== JWT Database storage methods ===== - - /** - * Save key to database (plaintext storage, don't pretend encryption helps) - */ - private async saveSecretToDB(secret: string, instanceId: string): Promise { - const secretData = { - secret, - generatedAt: new Date().toISOString(), - instanceId, - algorithm: "HS256" - }; - - const existing = await db - .select() - .from(settings) - .where(eq(settings.key, SystemCrypto.JWT_SECRET_DB_KEY)); - - const encodedData = JSON.stringify(secretData); - - if (existing.length > 0) { - await db - .update(settings) - .set({ value: encodedData }) - .where(eq(settings.key, SystemCrypto.JWT_SECRET_DB_KEY)); - } else { - await db.insert(settings).values({ - key: SystemCrypto.JWT_SECRET_DB_KEY, - value: encodedData, - }); - } - } - - /** - * Load key from database - */ - private async loadSecretFromDB(): Promise { - try { - const result = await db - .select() - .from(settings) - .where(eq(settings.key, SystemCrypto.JWT_SECRET_DB_KEY)); - - if (result.length === 0) { - return null; - } - - const secretData = JSON.parse(result[0].value); - - // Check key validity - if (!secretData.secret || secretData.secret.length < 64) { - databaseLogger.warn("Invalid JWT secret in database", { - operation: "jwt_db_invalid", - hasSecret: !!secretData.secret, - length: secretData.secret?.length || 0 - }); - return null; - } - - return secretData.secret; - } catch (error) { - databaseLogger.warn("Failed to load JWT secret from database", { - operation: "jwt_db_load_failed", - error: error instanceof Error ? error.message : "Unknown error", - }); - return null; - } - } - - /** - * Regenerate JWT secret (admin function) - */ - async regenerateJWTSecret(): Promise { - databaseLogger.warn("🔄 Regenerating JWT secret - ALL TOKENS WILL BE INVALIDATED", { - operation: "jwt_regenerate", - }); - - await this.generateAndStoreSecret(); - - databaseLogger.success("JWT secret regenerated successfully", { - operation: "jwt_regenerated", - warning: "All existing JWT tokens are now invalid", - }); - - return this.jwtSecret!; - } /** * Validate JWT secret system @@ -412,36 +206,6 @@ class SystemCrypto { const isValid = await this.validateJWTSecret(); const hasSecret = this.jwtSecret !== null; - // Check file storage - let hasFileStorage = false; - try { - await fs.access(SystemCrypto.JWT_SECRET_FILE); - hasFileStorage = true; - } catch { - // File doesn't exist - } - - // Check database storage - let hasDBStorage = false; - let dbInfo = null; - try { - const result = await db - .select() - .from(settings) - .where(eq(settings.key, SystemCrypto.JWT_SECRET_DB_KEY)); - - if (result.length > 0) { - hasDBStorage = true; - const secretData = JSON.parse(result[0].value); - dbInfo = { - generatedAt: secretData.generatedAt, - instanceId: secretData.instanceId, - algorithm: secretData.algorithm - }; - } - } catch (error) { - // Database read failed - } // Check environment variable const hasEnvVar = !!(process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64); @@ -450,11 +214,8 @@ class SystemCrypto { hasSecret, isValid, storage: { - environment: hasEnvVar, - file: hasFileStorage, - database: hasDBStorage + environment: hasEnvVar }, - dbInfo, algorithm: "HS256", note: "Using simplified key management without encryption layers" }; diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index b8e3fb1c..e067435e 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -213,7 +213,15 @@ export const Terminal = forwardRef(function SSHTerminal( window.location.port === "5173" || window.location.port === ""); - const wsUrl = isDev + // Get JWT token for WebSocket authentication + const jwtToken = localStorage.getItem("jwt"); + if (!jwtToken) { + console.error("No JWT token available for WebSocket connection"); + setConnectionStatus("disconnected"); + return; + } + + const baseWsUrl = isDev ? "ws://localhost:8082" : isElectron() ? (() => { @@ -227,6 +235,9 @@ export const Terminal = forwardRef(function SSHTerminal( })() : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`; + // Add JWT token as query parameter for authentication + const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`; + const ws = new WebSocket(wsUrl); webSocketRef.current = ws; wasDisconnectedBySSH.current = false; @@ -351,6 +362,24 @@ export const Terminal = forwardRef(function SSHTerminal( if (terminal) { terminal.clear(); } + + // Handle authentication errors (code 1008) + if (event.code === 1008) { + console.error("WebSocket authentication failed:", event.reason); + setConnectionError("Authentication failed - please re-login"); + setIsConnecting(false); + shouldNotReconnectRef.current = true; + + // Clear invalid JWT token + localStorage.removeItem("jwt"); + + // Show authentication error message + toast.error("Authentication failed. Please log in again."); + + // Don't attempt to reconnect on auth failure + return; + } + setIsConnecting(true); if ( !wasDisconnectedBySSH.current && diff --git a/src/ui/Mobile/Apps/Terminal/Terminal.tsx b/src/ui/Mobile/Apps/Terminal/Terminal.tsx index b69a8f1f..3bb4ee1b 100644 --- a/src/ui/Mobile/Apps/Terminal/Terminal.tsx +++ b/src/ui/Mobile/Apps/Terminal/Terminal.tsx @@ -147,7 +147,19 @@ export const Terminal = forwardRef(function SSHTerminal( } catch (error) {} }); - ws.addEventListener("close", () => { + ws.addEventListener("close", (event) => { + // Handle authentication errors (code 1008) + if (event.code === 1008) { + console.error("WebSocket authentication failed:", event.reason); + terminal.writeln(`\r\n[Authentication failed - please re-login]`); + + // Clear invalid JWT token + localStorage.removeItem("jwt"); + + // Don't attempt to reconnect on auth failure + return; + } + if (!wasDisconnectedBySSH.current) { terminal.writeln(`\r\n[${t("terminal.connectionClosed")}]`); } @@ -240,7 +252,15 @@ export const Terminal = forwardRef(function SSHTerminal( window.location.port === "5173" || window.location.port === ""); - const wsUrl = isDev + // Get JWT token for WebSocket authentication + const jwtToken = localStorage.getItem("jwt"); + if (!jwtToken) { + console.error("No JWT token available for WebSocket connection"); + setConnectionStatus("disconnected"); + return; + } + + const baseWsUrl = isDev ? "ws://localhost:8082" : isElectron() ? (() => { @@ -255,6 +275,9 @@ export const Terminal = forwardRef(function SSHTerminal( })() : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`; + // Add JWT token as query parameter for authentication + const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`; + const ws = new WebSocket(wsUrl); webSocketRef.current = ws; wasDisconnectedBySSH.current = false;