diff --git a/package-lock.json b/package-lock.json index 4ac96ca2..3c78305d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", @@ -3128,6 +3129,39 @@ } } }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "license": "MIT", diff --git a/package.json b/package.json index b4b8af5c..6818fea9 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index faf3c61b..13a74ea9 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -167,6 +167,7 @@ async function initializeCompleteDatabase(): Promise { enable_file_manager INTEGER NOT NULL DEFAULT 1, default_path TEXT, stats_config TEXT, + terminal_config TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) @@ -404,6 +405,7 @@ const migrateSchema = () => { addColumnIfNotExists("ssh_data", "autostart_key", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT"); addColumnIfNotExists("ssh_data", "stats_config", "TEXT"); + addColumnIfNotExists("ssh_data", "terminal_config", "TEXT"); addColumnIfNotExists("ssh_credentials", "private_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 46cbb7f9..d00fa94e 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -66,6 +66,7 @@ export const sshData = sqliteTable("ssh_data", { .default(true), defaultPath: text("default_path"), statsConfig: text("stats_config"), + terminalConfig: text("terminal_config"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index ab8f317d..091e892e 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -235,6 +235,7 @@ router.post( defaultPath, tunnelConnections, statsConfig, + terminalConfig, } = hostData; if ( !isNonEmptyString(userId) || @@ -271,6 +272,7 @@ router.post( enableFileManager: enableFileManager ? 1 : 0, defaultPath: defaultPath || null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, + terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, }; if (effectiveAuthType === "password") { @@ -421,6 +423,7 @@ router.put( defaultPath, tunnelConnections, statsConfig, + terminalConfig, } = hostData; if ( !isNonEmptyString(userId) || @@ -458,6 +461,7 @@ router.put( enableFileManager: enableFileManager ? 1 : 0, defaultPath: defaultPath || null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, + terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, }; if (effectiveAuthType === "password") { @@ -604,6 +608,9 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => { statsConfig: row.statsConfig ? JSON.parse(row.statsConfig as string) : undefined, + terminalConfig: row.terminalConfig + ? JSON.parse(row.terminalConfig as string) + : undefined, }; return (await resolveHostCredentials(baseHost)) || baseHost; @@ -671,6 +678,9 @@ router.get( statsConfig: host.statsConfig ? JSON.parse(host.statsConfig) : undefined, + terminalConfig: host.terminalConfig + ? JSON.parse(host.terminalConfig) + : undefined, }; res.json((await resolveHostCredentials(result)) || result); diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 1fce11f7..42b80cfb 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -331,20 +331,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { } } else if (resolvedCredentials.authType === "password") { if (!resolvedCredentials.password || !resolvedCredentials.password.trim()) { - fileLogger.warn( - "Password authentication requested but no password provided", - { - operation: "file_connect", - sessionId, - hostId, - }, - ); return res .status(400) .json({ error: "Password required for password authentication" }); } - // Set password to offer both password and keyboard-interactive methods - config.password = resolvedCredentials.password; + } else if (resolvedCredentials.authType === "none") { + // Don't set password in config - rely on keyboard-interactive } else { fileLogger.warn( "No valid authentication method provided for file manager", @@ -458,15 +450,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { finish: (responses: string[]) => void, ) => { const promptTexts = prompts.map((p) => p.prompt); - fileLogger.info("Keyboard-interactive authentication requested", { - operation: "file_keyboard_interactive", - hostId, - sessionId, - promptsCount: prompts.length, - prompts: promptTexts, - alreadyResponded: keyboardInteractiveResponded, - }); - const totpPromptIndex = prompts.findIndex((p) => /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( p.prompt, @@ -474,26 +457,26 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { ); if (totpPromptIndex !== -1) { - // TOTP prompt detected - need user input if (responseSent) { - fileLogger.warn("Response already sent, ignoring TOTP prompt", { - operation: "file_keyboard_interactive", - hostId, - sessionId, + const responses = prompts.map((p) => { + if (/password/i.test(p.prompt) && resolvedCredentials.password) { + return resolvedCredentials.password; + } + return ""; }); + finish(responses); return; } responseSent = true; if (pendingTOTPSessions[sessionId]) { - fileLogger.warn( - "TOTP session already exists, ignoring duplicate keyboard-interactive", - { - operation: "file_keyboard_interactive", - hostId, - sessionId, - }, - ); + const responses = prompts.map((p) => { + if (/password/i.test(p.prompt) && resolvedCredentials.password) { + return resolvedCredentials.password; + } + return ""; + }); + finish(responses); return; } @@ -515,36 +498,75 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { resolvedPassword: resolvedCredentials.password, }; - fileLogger.info("Created TOTP session", { - operation: "file_keyboard_interactive_totp", - hostId, - sessionId, - prompt: prompts[totpPromptIndex].prompt, - promptsCount: prompts.length, - }); - res.json({ requires_totp: true, sessionId, prompt: prompts[totpPromptIndex].prompt, }); } else { - // Non-TOTP prompts (password, etc.) - respond automatically - if (keyboardInteractiveResponded) { - fileLogger.warn( - "Already responded to keyboard-interactive, ignoring subsequent prompt", - { - operation: "file_keyboard_interactive", - hostId, - sessionId, - prompts: promptTexts, - }, - ); + // Non-TOTP prompts (password, etc.) + const hasStoredPassword = + resolvedCredentials.password && + resolvedCredentials.authType !== "none"; + + // Check if this is a password prompt + const passwordPromptIndex = prompts.findIndex((p) => + /password/i.test(p.prompt), + ); + + // If no stored password (including authType "none"), prompt the user + if (!hasStoredPassword && passwordPromptIndex !== -1) { + if (responseSent) { + const responses = prompts.map((p) => { + if (/password/i.test(p.prompt) && resolvedCredentials.password) { + return resolvedCredentials.password; + } + return ""; + }); + finish(responses); + return; + } + responseSent = true; + + if (pendingTOTPSessions[sessionId]) { + const responses = prompts.map((p) => { + if (/password/i.test(p.prompt) && resolvedCredentials.password) { + return resolvedCredentials.password; + } + return ""; + }); + finish(responses); + return; + } + + keyboardInteractiveResponded = true; + + pendingTOTPSessions[sessionId] = { + client, + finish, + config, + createdAt: Date.now(), + sessionId, + hostId, + ip, + port, + username, + userId, + prompts, + totpPromptIndex: passwordPromptIndex, + resolvedPassword: resolvedCredentials.password, + }; + + res.json({ + requires_totp: true, + sessionId, + prompt: prompts[passwordPromptIndex].prompt, + isPassword: true, + }); return; } - keyboardInteractiveResponded = true; - + // Auto-respond with stored credentials if available const responses = prompts.map((p) => { if (/password/i.test(p.prompt) && resolvedCredentials.password) { return resolvedCredentials.password; @@ -552,16 +574,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { return ""; }); - fileLogger.info("Auto-responding to keyboard-interactive prompts", { - operation: "file_keyboard_interactive_response", - hostId, - sessionId, - hasPassword: !!resolvedCredentials.password, - responsesProvided: responses.filter((r) => r !== "").length, - totalPrompts: prompts.length, - prompts: promptTexts, - }); - + keyboardInteractiveResponded = true; finish(responses); } }, @@ -619,14 +632,6 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { .json({ error: "TOTP session timeout. Please reconnect." }); } - fileLogger.info("Submitting TOTP code to SSH server", { - operation: "file_totp_verify", - sessionId, - userId, - codeLength: totpCode.length, - promptsCount: session.prompts?.length || 0, - }); - // Build responses for ALL prompts, just like in terminal.ts const responses = (session.prompts || []).map((p, index) => { if (index === session.totpPromptIndex) { @@ -649,9 +654,9 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { let responseSent = false; let responseTimeout: NodeJS.Timeout; - // Remove old event listeners from /connect endpoint to avoid conflicts - session.client.removeAllListeners("ready"); - session.client.removeAllListeners("error"); + // Don't remove event listeners - just add our own 'once' handlers + // The ssh2 library manages multiple listeners correctly + // Removing them can cause the connection to become unstable // CRITICAL: Attach event listeners BEFORE calling finish() to avoid race condition session.client.once("ready", () => { @@ -661,78 +666,69 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { delete pendingTOTPSessions[sessionId]; - sshSessions[sessionId] = { - client: session.client, - isConnected: true, - lastActive: Date.now(), - }; - scheduleSessionCleanup(sessionId); + // Add a small delay to let SSH2 stabilize the connection after keyboard-interactive + // This prevents "Not connected" errors when immediately trying to exec commands + setTimeout(() => { + sshSessions[sessionId] = { + client: session.client, + isConnected: true, + lastActive: Date.now(), + }; + scheduleSessionCleanup(sessionId); - fileLogger.success("TOTP verification successful", { - operation: "file_totp_verify", - sessionId, - userId, - }); + res.json({ + status: "success", + message: "TOTP verified, SSH connection established", + }); - res.json({ - status: "success", - message: "TOTP verified, SSH connection established", - }); - - // Log activity to dashboard API - if (session.hostId && session.userId) { - (async () => { - try { - const hosts = await SimpleDBOps.select( - getDb() - .select() - .from(sshData) - .where( - and( - eq(sshData.id, session.hostId!), - eq(sshData.userId, session.userId!), + // Log activity to dashboard API after connection is stable + if (session.hostId && session.userId) { + (async () => { + try { + const hosts = await SimpleDBOps.select( + getDb() + .select() + .from(sshData) + .where( + and( + eq(sshData.id, session.hostId!), + eq(sshData.userId, session.userId!), + ), ), - ), - "ssh_data", - session.userId!, - ); + "ssh_data", + session.userId!, + ); - const hostName = - hosts.length > 0 && hosts[0].name - ? hosts[0].name - : `${session.username}@${session.ip}:${session.port}`; + const hostName = + hosts.length > 0 && hosts[0].name + ? hosts[0].name + : `${session.username}@${session.ip}:${session.port}`; - const authManager = AuthManager.getInstance(); - await axios.post( - "http://localhost:30006/activity/log", - { - type: "file_manager", - hostId: session.hostId, - hostName, - }, - { - headers: { - Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`, + const authManager = AuthManager.getInstance(); + await axios.post( + "http://localhost:30006/activity/log", + { + type: "file_manager", + hostId: session.hostId, + hostName, }, - }, - ); - - fileLogger.info("File manager activity logged (TOTP)", { - operation: "activity_log", - userId: session.userId, - hostId: session.hostId, - hostName, - }); - } catch (error) { - fileLogger.warn("Failed to log file manager activity (TOTP)", { - operation: "activity_log_error", - userId: session.userId, - hostId: session.hostId, - error: error instanceof Error ? error.message : "Unknown error", - }); - } - })(); - } + { + headers: { + Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`, + }, + }, + ); + } catch (error) { + fileLogger.warn("Failed to log file manager activity (TOTP)", { + operation: "activity_log_error", + userId: session.userId, + hostId: session.hostId, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + })(); + } + }, 200); // Give SSH2 connection 200ms to fully stabilize after keyboard-interactive }); session.client.once("error", (err) => { diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 42ab41c7..8bedc87e 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -112,13 +112,9 @@ class SSHConnectionPool { ); if (totpPrompt) { - statsLogger.warn( - `Server Stats cannot handle TOTP for host ${host.ip}. Connection will fail.`, - { - operation: "server_stats_totp_detected", - hostId: host.id, - }, - ); + // Record TOTP failure as permanent - never retry + // The recordFailure method will log this once + authFailureTracker.recordFailure(host.id, "TOTP", true); client.end(); reject( new Error( @@ -272,9 +268,109 @@ class MetricsCache { } } +interface AuthFailureRecord { + count: number; + lastFailure: number; + reason: "TOTP" | "AUTH" | "TIMEOUT"; + permanent: boolean; // If true, don't retry at all +} + +class AuthFailureTracker { + private failures = new Map(); + private maxRetries = 3; + private backoffBase = 60000; // 1 minute base backoff + + recordFailure( + hostId: number, + reason: "TOTP" | "AUTH" | "TIMEOUT", + permanent = false, + ): void { + const existing = this.failures.get(hostId); + if (existing) { + existing.count++; + existing.lastFailure = Date.now(); + existing.reason = reason; + if (permanent) existing.permanent = true; + } else { + this.failures.set(hostId, { + count: 1, + lastFailure: Date.now(), + reason, + permanent, + }); + } + } + + shouldSkip(hostId: number): boolean { + const record = this.failures.get(hostId); + if (!record) return false; + + // Always skip TOTP hosts + if (record.reason === "TOTP" || record.permanent) { + return true; + } + + // Skip if we've exceeded max retries + if (record.count >= this.maxRetries) { + return true; + } + + // Calculate exponential backoff + const backoffTime = this.backoffBase * Math.pow(2, record.count - 1); + const timeSinceFailure = Date.now() - record.lastFailure; + + return timeSinceFailure < backoffTime; + } + + getSkipReason(hostId: number): string | null { + const record = this.failures.get(hostId); + if (!record) return null; + + if (record.reason === "TOTP") { + return "TOTP authentication required (metrics unavailable)"; + } + + if (record.permanent) { + return "Authentication permanently failed"; + } + + if (record.count >= this.maxRetries) { + return `Too many authentication failures (${record.count} attempts)`; + } + + const backoffTime = this.backoffBase * Math.pow(2, record.count - 1); + const timeSinceFailure = Date.now() - record.lastFailure; + const remainingTime = Math.ceil((backoffTime - timeSinceFailure) / 1000); + + if (timeSinceFailure < backoffTime) { + return `Retry in ${remainingTime}s (attempt ${record.count}/${this.maxRetries})`; + } + + return null; + } + + reset(hostId: number): void { + this.failures.delete(hostId); + // Don't log reset - it's not important + } + + cleanup(): void { + // Clean up old failures (older than 1 hour) + const maxAge = 60 * 60 * 1000; + const now = Date.now(); + + for (const [hostId, record] of this.failures.entries()) { + if (!record.permanent && now - record.lastFailure > maxAge) { + this.failures.delete(hostId); + } + } + } +} + const connectionPool = new SSHConnectionPool(); const requestQueue = new RequestQueue(); const metricsCache = new MetricsCache(); +const authFailureTracker = new AuthFailureTracker(); const authManager = AuthManager.getInstance(); type HostStatus = "online" | "offline"; @@ -729,6 +825,13 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ os: string | null; }; }> { + // Check if we should skip this host due to auth failures + if (authFailureTracker.shouldSkip(host.id)) { + const reason = authFailureTracker.getSkipReason(host.id); + // Don't log - just skip silently to avoid spam + throw new Error(reason || "Authentication failed"); + } + const cached = metricsCache.get(host.id); if (cached) { return cached as ReturnType extends Promise @@ -1070,11 +1173,32 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ return result; }); } catch (error) { - if ( - error instanceof Error && - error.message.includes("TOTP authentication required") - ) { - throw error; + // Record authentication failures for backoff + if (error instanceof Error) { + if (error.message.includes("TOTP authentication required")) { + // TOTP failures are already recorded in keyboard-interactive handler + throw error; + } else if ( + error.message.includes("No password available") || + error.message.includes("Unsupported authentication type") || + error.message.includes("No SSH key available") + ) { + // Configuration errors - permanent failures, don't retry + // recordFailure will log once when first detected + authFailureTracker.recordFailure(host.id, "AUTH", true); + } else if ( + error.message.includes("authentication") || + error.message.includes("Permission denied") || + error.message.includes("All configured authentication methods failed") + ) { + // recordFailure will log once when first detected + authFailureTracker.recordFailure(host.id, "AUTH"); + } else if ( + error.message.includes("timeout") || + error.message.includes("ETIMEDOUT") + ) { + authFailureTracker.recordFailure(host.id, "TIMEOUT"); + } } throw error; } @@ -1257,13 +1381,17 @@ app.get("/metrics/:id", validateHostId, async (req, res) => { const metrics = await collectMetrics(host); res.json({ ...metrics, lastChecked: new Date().toISOString() }); } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + + // Check if this is a skip due to auth failure tracking if ( - err instanceof Error && - err.message.includes("TOTP authentication required") + errorMessage.includes("TOTP authentication required") || + errorMessage.includes("metrics unavailable") ) { + // Don't log as error - this is expected for TOTP hosts return res.status(403).json({ error: "TOTP_REQUIRED", - message: "Server Stats unavailable for TOTP-enabled servers", + message: errorMessage, cpu: { percent: null, cores: null, load: null }, memory: { percent: null, usedGiB: null, totalGiB: null }, disk: { @@ -1280,7 +1408,43 @@ app.get("/metrics/:id", validateHostId, async (req, res) => { }); } - statsLogger.error("Failed to collect metrics", err); + // Check if this is a skip due to too many failures or config issues + if ( + errorMessage.includes("Too many authentication failures") || + errorMessage.includes("Retry in") || + errorMessage.includes("Invalid configuration") || + errorMessage.includes("Authentication failed") + ) { + // Don't log - return error silently to avoid spam + return res.status(429).json({ + error: "UNAVAILABLE", + message: errorMessage, + cpu: { percent: null, cores: null, load: null }, + memory: { percent: null, usedGiB: null, totalGiB: null }, + disk: { + percent: null, + usedHuman: null, + totalHuman: null, + availableHuman: null, + }, + network: { interfaces: [] }, + uptime: { seconds: null, formatted: null }, + processes: { total: null, running: null, top: [] }, + system: { hostname: null, kernel: null, os: null }, + lastChecked: new Date().toISOString(), + }); + } + + // Only log unexpected errors + if ( + !errorMessage.includes("timeout") && + !errorMessage.includes("offline") && + !errorMessage.includes("permanently") && + !errorMessage.includes("none") && + !errorMessage.includes("No password") + ) { + statsLogger.error("Failed to collect metrics", err); + } if (err instanceof Error && err.message.includes("timeout")) { return res.status(504).json({ @@ -1339,4 +1503,12 @@ app.listen(PORT, async () => { operation: "auth_init_error", }); } + + // Cleanup old auth failures every 10 minutes + setInterval( + () => { + authFailureTracker.cleanup(); + }, + 10 * 60 * 1000, + ); }); diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 0f381440..67a71fac 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -313,12 +313,6 @@ wss.on("connection", async (ws: WebSocket, req) => { const totpData = data as TOTPResponseData; if (keyboardInteractiveFinish && totpData?.code) { const totpCode = totpData.code; - sshLogger.info("TOTP code received from user", { - operation: "totp_response", - userId, - codeLength: totpCode.length, - }); - keyboardInteractiveFinish([totpCode]); keyboardInteractiveFinish = null; } else { @@ -512,177 +506,167 @@ wss.on("connection", async (ws: WebSocket, req) => { sshConn.on("ready", () => { clearTimeout(connectionTimeout); - // Small delay to let connection stabilize after keyboard-interactive auth - // This helps prevent "No response from server" errors with TOTP - setTimeout(() => { - // Check if connection still exists (might have been cleaned up) - if (!sshConn) { - sshLogger.warn( - "SSH connection was cleaned up before shell could be created", - { + // Immediately try to create shell - don't delay as it can cause connection to be cleaned up + // The connection is already ready at this point + if (!sshConn) { + sshLogger.warn( + "SSH connection was cleaned up before shell could be created", + { + operation: "ssh_shell", + hostId: id, + ip, + port, + username, + }, + ); + ws.send( + JSON.stringify({ + type: "error", + message: + "SSH connection was closed before terminal could be created", + }), + ); + return; + } + + sshConn.shell( + { + rows: data.rows, + cols: data.cols, + term: "xterm-256color", + } as PseudoTtyOptions, + (err, stream) => { + if (err) { + sshLogger.error("Shell error", err, { operation: "ssh_shell", hostId: id, ip, port, username, - }, - ); - ws.send( - JSON.stringify({ - type: "error", - message: - "SSH connection was closed before terminal could be created", - }), - ); - return; - } - - sshConn.shell( - { - rows: data.rows, - cols: data.cols, - term: "xterm-256color", - } as PseudoTtyOptions, - (err, stream) => { - if (err) { - sshLogger.error("Shell error", err, { - operation: "ssh_shell", - hostId: id, - ip, - port, - username, - }); - ws.send( - JSON.stringify({ - type: "error", - message: "Shell error: " + err.message, - }), - ); - return; - } - - sshStream = stream; - - stream.on("data", (data: Buffer) => { - try { - const utf8String = data.toString("utf-8"); - ws.send(JSON.stringify({ type: "data", data: utf8String })); - } catch (error) { - sshLogger.error("Error encoding terminal data", error, { - operation: "terminal_data_encoding", - hostId: id, - dataLength: data.length, - }); - ws.send( - JSON.stringify({ - type: "data", - data: data.toString("latin1"), - }), - ); - } }); - - stream.on("close", () => { - ws.send( - JSON.stringify({ - type: "disconnected", - message: "Connection lost", - }), - ); - }); - - stream.on("error", (err: Error) => { - sshLogger.error("SSH stream error", err, { - operation: "ssh_stream", - hostId: id, - ip, - port, - username, - }); - ws.send( - JSON.stringify({ - type: "error", - message: "SSH stream error: " + err.message, - }), - ); - }); - - setupPingInterval(); - - if (initialPath && initialPath.trim() !== "") { - const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`; - stream.write(cdCommand); - } - - if (executeCommand && executeCommand.trim() !== "") { - setTimeout(() => { - const command = `${executeCommand}\n`; - stream.write(command); - }, 500); - } - ws.send( - JSON.stringify({ type: "connected", message: "SSH connected" }), + JSON.stringify({ + type: "error", + message: "Shell error: " + err.message, + }), ); + return; + } - // Log activity to dashboard API - if (id && hostConfig.userId) { - (async () => { - try { - // Fetch host name from database - const hosts = await SimpleDBOps.select( - getDb() - .select() - .from(sshData) - .where( - and( - eq(sshData.id, id), - eq(sshData.userId, hostConfig.userId!), - ), + sshStream = stream; + + stream.on("data", (data: Buffer) => { + try { + const utf8String = data.toString("utf-8"); + ws.send(JSON.stringify({ type: "data", data: utf8String })); + } catch (error) { + sshLogger.error("Error encoding terminal data", error, { + operation: "terminal_data_encoding", + hostId: id, + dataLength: data.length, + }); + ws.send( + JSON.stringify({ + type: "data", + data: data.toString("latin1"), + }), + ); + } + }); + + stream.on("close", () => { + ws.send( + JSON.stringify({ + type: "disconnected", + message: "Connection lost", + }), + ); + }); + + stream.on("error", (err: Error) => { + sshLogger.error("SSH stream error", err, { + operation: "ssh_stream", + hostId: id, + ip, + port, + username, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "SSH stream error: " + err.message, + }), + ); + }); + + setupPingInterval(); + + if (initialPath && initialPath.trim() !== "") { + const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`; + stream.write(cdCommand); + } + + if (executeCommand && executeCommand.trim() !== "") { + setTimeout(() => { + const command = `${executeCommand}\n`; + stream.write(command); + }, 500); + } + + ws.send( + JSON.stringify({ type: "connected", message: "SSH connected" }), + ); + + // Log activity to dashboard API + if (id && hostConfig.userId) { + (async () => { + try { + // Fetch host name from database + const hosts = await SimpleDBOps.select( + getDb() + .select() + .from(sshData) + .where( + and( + eq(sshData.id, id), + eq(sshData.userId, hostConfig.userId!), ), - "ssh_data", - hostConfig.userId!, - ); + ), + "ssh_data", + hostConfig.userId!, + ); - const hostName = - hosts.length > 0 && hosts[0].name - ? hosts[0].name - : `${username}@${ip}:${port}`; + const hostName = + hosts.length > 0 && hosts[0].name + ? hosts[0].name + : `${username}@${ip}:${port}`; - await axios.post( - "http://localhost:30006/activity/log", - { - type: "terminal", - hostId: id, - hostName, - }, - { - headers: { - Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`, - }, - }, - ); - - sshLogger.info("Terminal activity logged", { - operation: "activity_log", - userId: hostConfig.userId, + await axios.post( + "http://localhost:30006/activity/log", + { + type: "terminal", hostId: id, hostName, - }); - } catch (error) { - sshLogger.warn("Failed to log terminal activity", { - operation: "activity_log_error", - userId: hostConfig.userId, - hostId: id, - error: - error instanceof Error ? error.message : "Unknown error", - }); - } - })(); - } - }, - ); - }, 100); // Small delay to stabilize connection after keyboard-interactive auth + }, + { + headers: { + Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`, + }, + }, + ); + } catch (error) { + sshLogger.warn("Failed to log terminal activity", { + operation: "activity_log_error", + userId: hostConfig.userId, + hostId: id, + error: + error instanceof Error ? error.message : "Unknown error", + }); + } + })(); + } + }, + ); }); sshConn.on("error", (err: Error) => { @@ -738,6 +722,13 @@ wss.on("connection", async (ws: WebSocket, req) => { sshConn.on("close", () => { clearTimeout(connectionTimeout); + sshLogger.warn("SSH connection closed by server", { + operation: "ssh_close", + hostId: id, + ip, + port, + hadStream: !!sshStream, + }); cleanupSSH(connectionTimeout); }); @@ -751,17 +742,6 @@ wss.on("connection", async (ws: WebSocket, req) => { finish: (responses: string[]) => void, ) => { const promptTexts = prompts.map((p) => p.prompt); - sshLogger.info("Keyboard-interactive authentication requested", { - operation: "ssh_keyboard_interactive", - hostId: id, - promptsCount: prompts.length, - instructions: instructions || "none", - }); - console.log( - `[SSH Keyboard-Interactive] Host ${id}: ${prompts.length} prompts:`, - promptTexts, - ); - const totpPromptIndex = prompts.findIndex((p) => /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( p.prompt, @@ -769,11 +749,11 @@ wss.on("connection", async (ws: WebSocket, req) => { ); if (totpPromptIndex !== -1) { - // TOTP prompt detected - need user input if (totpPromptSent) { - sshLogger.warn("TOTP prompt already sent, ignoring duplicate", { - operation: "ssh_keyboard_interactive", + sshLogger.warn("TOTP prompt asked again - ignoring duplicate", { + operation: "ssh_keyboard_interactive_totp_duplicate", hostId: id, + prompts: promptTexts, }); return; } @@ -783,7 +763,6 @@ wss.on("connection", async (ws: WebSocket, req) => { keyboardInteractiveFinish = (totpResponses: string[]) => { const totpCode = (totpResponses[0] || "").trim(); - // Respond to ALL prompts, not just TOTP const responses = prompts.map((p, index) => { if (index === totpPromptIndex) { return totpCode; @@ -794,14 +773,6 @@ wss.on("connection", async (ws: WebSocket, req) => { return ""; }); - sshLogger.info("TOTP response being sent to SSH server", { - operation: "totp_verification", - hostId: id, - totpCodeLength: totpCode.length, - totalPrompts: prompts.length, - responsesProvided: responses.filter((r) => r !== "").length, - }); - finish(responses); }; ws.send( @@ -811,68 +782,57 @@ wss.on("connection", async (ws: WebSocket, req) => { }), ); } else { - // Non-TOTP prompts (password, etc.) - if (keyboardInteractiveResponded) { - sshLogger.warn( - "Already responded to keyboard-interactive, ignoring subsequent prompt", - { - operation: "ssh_keyboard_interactive", - hostId: id, - prompts: promptTexts, - }, - ); - return; - } - keyboardInteractiveResponded = true; - - // Check if we have stored credentials for auto-response const hasStoredPassword = resolvedCredentials.password && resolvedCredentials.authType !== "none"; - if (!hasStoredPassword && resolvedCredentials.authType === "none") { - // For "none" auth type, prompt user for all keyboard-interactive inputs - const passwordPromptIndex = prompts.findIndex((p) => - /password/i.test(p.prompt), - ); + // Check if this is a password prompt + const passwordPromptIndex = prompts.findIndex((p) => + /password/i.test(p.prompt), + ); - if (passwordPromptIndex !== -1) { - // Ask user for password - keyboardInteractiveFinish = (userResponses: string[]) => { - const userInput = (userResponses[0] || "").trim(); - - // Build responses for all prompts - const responses = prompts.map((p, index) => { - if (index === passwordPromptIndex) { - return userInput; - } - return ""; - }); - - sshLogger.info( - "User-provided password being sent to SSH server", - { - operation: "interactive_password_verification", - hostId: id, - passwordLength: userInput.length, - totalPrompts: prompts.length, - }, - ); - - finish(responses); - }; - - ws.send( - JSON.stringify({ - type: "password_required", - prompt: prompts[passwordPromptIndex].prompt, - }), - ); + // If no stored password (including authType "none"), prompt the user + if (!hasStoredPassword && passwordPromptIndex !== -1) { + if (keyboardInteractiveResponded) { return; } + keyboardInteractiveResponded = true; + + keyboardInteractiveFinish = (userResponses: string[]) => { + const userInput = (userResponses[0] || "").trim(); + + // Build responses for all prompts + const responses = prompts.map((p, index) => { + if (index === passwordPromptIndex) { + return userInput; + } + return ""; + }); + + sshLogger.info( + "User-provided password being sent to SSH server", + { + operation: "interactive_password_verification", + hostId: id, + passwordLength: userInput.length, + totalPrompts: prompts.length, + }, + ); + + finish(responses); + }; + + ws.send( + JSON.stringify({ + type: "password_required", + prompt: prompts[passwordPromptIndex].prompt, + }), + ); + return; } - // Auto-respond with stored credentials + // Auto-respond with stored credentials if available + // Allow multiple responses - the server might ask multiple times during auth flow const responses = prompts.map((p) => { if (/password/i.test(p.prompt) && resolvedCredentials.password) { return resolvedCredentials.password; @@ -880,18 +840,7 @@ wss.on("connection", async (ws: WebSocket, req) => { return ""; }); - sshLogger.info("Responding to keyboard-interactive prompts", { - operation: "ssh_keyboard_interactive_response", - hostId: id, - hasPassword: !!resolvedCredentials.password, - responsesProvided: responses.filter((r) => r !== "").length, - totalPrompts: prompts.length, - prompts: promptTexts, - }); - - console.log( - `[SSH Auto Response] Host ${id}: Sending ${responses.length} responses, ${responses.filter((r) => r !== "").length} non-empty`, - ); + keyboardInteractiveResponded = true; finish(responses); } }, @@ -963,18 +912,7 @@ wss.on("connection", async (ws: WebSocket, req) => { }; if (resolvedCredentials.authType === "none") { - // No credentials provided - rely entirely on keyboard-interactive authentication - // This mimics the behavior of the ssh command-line client where it prompts for password/TOTP - sshLogger.info( - "Using interactive authentication (no stored credentials)", - { - operation: "ssh_auth_none", - hostId: id, - ip, - username, - }, - ); - // Don't set password or privateKey - let keyboard-interactive handle everything + // Don't set password in config - rely on keyboard-interactive } else if ( resolvedCredentials.authType === "key" && resolvedCredentials.key @@ -1030,8 +968,6 @@ wss.on("connection", async (ws: WebSocket, req) => { ); return; } - // Set password to offer both password and keyboard-interactive methods - connectConfig.password = resolvedCredentials.password; } else { sshLogger.error("No valid authentication method provided"); ws.send( diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 003c215a..1aecde15 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1264,7 +1264,9 @@ "enterNewPassword": "Enter your new password for user:", "passwordResetSuccess": "Success!", "passwordResetSuccessDesc": "Your password has been successfully reset! You can now log in with your new password.", - "signUp": "Sign Up" + "signUp": "Sign Up", + "authenticationDisabled": "Authentication Disabled", + "authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator." }, "errors": { "notFound": "Page not found", diff --git a/src/types/index.ts b/src/types/index.ts index a5787bc6..931dccf4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -36,6 +36,7 @@ export interface SSHHost { defaultPath: string; tunnelConnections: TunnelConnection[]; statsConfig?: string; + terminalConfig?: TerminalConfig; createdAt: string; updatedAt: string; } @@ -60,6 +61,7 @@ export interface SSHHostData { defaultPath?: string; tunnelConnections?: TunnelConnection[]; statsConfig?: string; + terminalConfig?: TerminalConfig; } // ============================================================================ @@ -248,6 +250,37 @@ export interface TermixAlert { actionText?: string; } +// ============================================================================ +// TERMINAL CONFIGURATION TYPES +// ============================================================================ + +export interface TerminalConfig { + // Appearance + cursorBlink: boolean; + cursorStyle: "block" | "underline" | "bar"; + fontSize: number; + fontFamily: string; + letterSpacing: number; + lineHeight: number; + theme: string; // Theme key from TERMINAL_THEMES + + // Behavior + scrollback: number; + bellStyle: "none" | "sound" | "visual" | "both"; + rightClickSelectsWord: boolean; + fastScrollModifier: "alt" | "ctrl" | "shift"; + fastScrollSensitivity: number; + minimumContrastRatio: number; + + // Advanced + backspaceMode: "normal" | "control-h"; + agentForwarding: boolean; + environmentVariables: Array<{ key: string; value: string }>; + startupSnippetId: number | null; + autoMosh: boolean; + moshCommand: string; +} + // ============================================================================ // TAB TYPES // ============================================================================ diff --git a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx index 95edeed5..2d146bb3 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx @@ -32,6 +32,7 @@ import { updateSSHHost, enableAutoStart, disableAutoStart, + getSnippets, } from "@/ui/main-axios.ts"; import { useTranslation } from "react-i18next"; import { CredentialSelector } from "@/ui/Desktop/Apps/Credentials/CredentialSelector.tsx"; @@ -41,6 +42,31 @@ import { EditorView } from "@codemirror/view"; import type { StatsConfig } from "@/types/stats-widgets"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; import { Checkbox } from "@/components/ui/checkbox.tsx"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select.tsx"; +import { Slider } from "@/components/ui/slider.tsx"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion.tsx"; +import { + TERMINAL_THEMES, + TERMINAL_FONTS, + CURSOR_STYLES, + BELL_STYLES, + FAST_SCROLL_MODIFIERS, + DEFAULT_TERMINAL_CONFIG, +} from "@/constants/terminal-themes"; +import { TerminalPreview } from "@/ui/Desktop/Apps/Terminal/TerminalPreview.tsx"; +import type { TerminalConfig } from "@/types"; +import { Plus, X } from "lucide-react"; interface SSHHost { id: number; @@ -69,6 +95,7 @@ interface SSHHost { autoStart: boolean; }>; statsConfig?: StatsConfig; + terminalConfig?: TerminalConfig; createdAt: string; updatedAt: string; credentialId?: number; @@ -89,6 +116,9 @@ export function HostManagerEditor({ const [credentials, setCredentials] = useState< Array<{ id: number; username: string; authType: string }> >([]); + const [snippets, setSnippets] = useState< + Array<{ id: number; name: string; content: string }> + >([]); const [authTab, setAuthTab] = useState< "password" | "key" | "credential" | "none" @@ -103,11 +133,13 @@ export function HostManagerEditor({ useEffect(() => { const fetchData = async () => { try { - const [hostsData, credentialsData] = await Promise.all([ + const [hostsData, credentialsData, snippetsData] = await Promise.all([ getSSHHosts(), getCredentials(), + getSnippets(), ]); setCredentials(credentialsData); + setSnippets(Array.isArray(snippetsData) ? snippetsData : []); const uniqueFolders = [ ...new Set( @@ -239,6 +271,34 @@ export function HostManagerEditor({ "system", ], }), + terminalConfig: z + .object({ + cursorBlink: z.boolean(), + cursorStyle: z.enum(["block", "underline", "bar"]), + fontSize: z.number().min(8).max(24), + fontFamily: z.string(), + letterSpacing: z.number().min(-2).max(5), + lineHeight: z.number().min(1.0).max(2.0), + theme: z.string(), + scrollback: z.number().min(1000).max(50000), + bellStyle: z.enum(["none", "sound", "visual", "both"]), + rightClickSelectsWord: z.boolean(), + fastScrollModifier: z.enum(["alt", "ctrl", "shift"]), + fastScrollSensitivity: z.number().min(1).max(10), + minimumContrastRatio: z.number().min(1).max(21), + backspaceMode: z.enum(["normal", "control-h"]), + agentForwarding: z.boolean(), + environmentVariables: z.array( + z.object({ + key: z.string(), + value: z.string(), + }), + ), + startupSnippetId: z.number().nullable(), + autoMosh: z.boolean(), + moshCommand: z.string(), + }) + .optional(), }) .superRefine((data, ctx) => { if (data.authType === "none") { @@ -327,6 +387,7 @@ export function HostManagerEditor({ defaultPath: "/", tunnelConnections: [], statsConfig: DEFAULT_STATS_CONFIG, + terminalConfig: DEFAULT_TERMINAL_CONFIG, }, }); @@ -386,6 +447,7 @@ export function HostManagerEditor({ defaultPath: cleanedHost.defaultPath || "/", tunnelConnections: cleanedHost.tunnelConnections || [], statsConfig: cleanedHost.statsConfig || DEFAULT_STATS_CONFIG, + terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG, }; if (defaultAuthType === "password") { @@ -432,6 +494,7 @@ export function HostManagerEditor({ defaultPath: "/", tunnelConnections: [], statsConfig: DEFAULT_STATS_CONFIG, + terminalConfig: DEFAULT_TERMINAL_CONFIG, }; form.reset(defaultFormData); @@ -471,6 +534,7 @@ export function HostManagerEditor({ defaultPath: data.defaultPath || "/", tunnelConnections: data.tunnelConnections || [], statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG, + terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG, }; submitData.credentialId = null; @@ -1178,7 +1242,7 @@ export function HostManagerEditor({ - + )} /> +

+ Terminal Customization +

+ + + Appearance + +
+ + +
+ + ( + + Theme + + + Choose a color theme for the terminal + + + )} + /> + + {/* Font Family */} + ( + + Font Family + + + Select the font to use in the terminal + + + )} + /> + + {/* Font Size */} + ( + + Font Size: {field.value}px + + + field.onChange(value) + } + /> + + + Adjust the terminal font size + + + )} + /> + + {/* Letter Spacing */} + ( + + + Letter Spacing: {field.value}px + + + + field.onChange(value) + } + /> + + + Adjust spacing between characters + + + )} + /> + + {/* Line Height */} + ( + + Line Height: {field.value} + + + field.onChange(value) + } + /> + + + Adjust spacing between lines + + + )} + /> + + {/* Cursor Style */} + ( + + Cursor Style + + + Choose the cursor appearance + + + )} + /> + + {/* Cursor Blink */} + ( + +
+ Cursor Blink + + Enable cursor blinking animation + +
+ + + +
+ )} + /> +
+
+ + {/* Behavior Settings */} + + Behavior + + {/* Scrollback Buffer */} + ( + + + Scrollback Buffer: {field.value} lines + + + + field.onChange(value) + } + /> + + + Number of lines to keep in scrollback history + + + )} + /> + + {/* Bell Style */} + ( + + Bell Style + + + How to handle terminal bell (BEL character) + + + )} + /> + + {/* Right Click Selects Word */} + ( + +
+ Right Click Selects Word + + Right-clicking selects the word under cursor + +
+ + + +
+ )} + /> + + {/* Fast Scroll Modifier */} + ( + + Fast Scroll Modifier + + + Modifier key for fast scrolling + + + )} + /> + + {/* Fast Scroll Sensitivity */} + ( + + + Fast Scroll Sensitivity: {field.value} + + + + field.onChange(value) + } + /> + + + Scroll speed multiplier when modifier is held + + + )} + /> + + {/* Minimum Contrast Ratio */} + ( + + + Minimum Contrast Ratio: {field.value} + + + + field.onChange(value) + } + /> + + + Automatically adjust colors for better readability + + + )} + /> +
+
+ + {/* Advanced Settings */} + + Advanced + + {/* Agent Forwarding */} + ( + +
+ SSH Agent Forwarding + + Forward SSH authentication agent to remote host + +
+ + + +
+ )} + /> + + {/* Backspace Mode */} + ( + + Backspace Mode + + + Backspace key behavior for compatibility + + + )} + /> + + {/* Startup Snippet */} + ( + + Startup Snippet + + + Execute a snippet when the terminal connects + + + )} + /> + + {/* Auto MOSH */} + ( + +
+ Auto-MOSH + + Automatically run MOSH command on connect + +
+ + + +
+ )} + /> + + {/* MOSH Command */} + {form.watch("terminalConfig.autoMosh") && ( + ( + + MOSH Command + + + + + The MOSH command to execute + + + )} + /> + )} + + {/* Environment Variables */} +
+ + + Set custom environment variables for the terminal + session + + {form + .watch("terminalConfig.environmentVariables") + ?.map((_, index) => ( +
+ ( + + + + + + )} + /> + ( + + + + + + )} + /> + +
+ ))} + +
+
+
+
( const { t } = useTranslation(); const { instance: terminal, ref: xtermRef } = useXTerm(); + + const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }; + const themeColors = + TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors; + const backgroundColor = themeColors.background; const fitAddonRef = useRef(null); const webSocketRef = useRef(null); const resizeTimeout = useRef(null); @@ -84,6 +101,7 @@ export const Terminal = forwardRef( const [, setIsAuthenticated] = useState(false); const [totpRequired, setTotpRequired] = useState(false); const [totpPrompt, setTotpPrompt] = useState(""); + const [isPasswordPrompt, setIsPasswordPrompt] = useState(false); const isVisibleRef = useRef(false); const reconnectTimeoutRef = useRef(null); const reconnectAttempts = useRef(0); @@ -172,12 +190,13 @@ export const Terminal = forwardRef( if (webSocketRef.current && code) { webSocketRef.current.send( JSON.stringify({ - type: "totp_response", + type: isPasswordPrompt ? "password_response" : "totp_response", data: { code }, }), ); setTotpRequired(false); setTotpPrompt(""); + setIsPasswordPrompt(false); } } @@ -500,6 +519,65 @@ export const Terminal = forwardRef( // Log activity for recent connections logTerminalActivity(); + + // Execute post-connection actions + setTimeout(async () => { + // Merge default config with host-specific config + const terminalConfig = { + ...DEFAULT_TERMINAL_CONFIG, + ...hostConfig.terminalConfig, + }; + + // Set environment variables + if ( + terminalConfig.environmentVariables && + terminalConfig.environmentVariables.length > 0 + ) { + for (const envVar of terminalConfig.environmentVariables) { + if (envVar.key && envVar.value && ws.readyState === 1) { + ws.send( + JSON.stringify({ + type: "input", + data: `export ${envVar.key}="${envVar.value}"\n`, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + } + + // Execute startup snippet + if (terminalConfig.startupSnippetId) { + try { + const snippets = await getSnippets(); + const snippet = snippets.find( + (s: { id: number }) => + s.id === terminalConfig.startupSnippetId, + ); + if (snippet && ws.readyState === 1) { + ws.send( + JSON.stringify({ + type: "input", + data: snippet.content + "\n", + }), + ); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } catch (err) { + console.warn("Failed to execute startup snippet:", err); + } + } + + // Execute MOSH command + if (terminalConfig.autoMosh && ws.readyState === 1) { + ws.send( + JSON.stringify({ + type: "input", + data: terminalConfig.moshCommand + "\n", + }), + ); + } + }, 500); } else if (msg.type === "disconnected") { wasDisconnectedBySSH.current = true; setIsConnected(false); @@ -513,6 +591,15 @@ export const Terminal = forwardRef( } else if (msg.type === "totp_required") { setTotpRequired(true); setTotpPrompt(msg.prompt || "Verification code:"); + setIsPasswordPrompt(false); + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current); + connectionTimeoutRef.current = null; + } + } else if (msg.type === "password_required") { + setTotpRequired(true); + setTotpPrompt(msg.prompt || "Password:"); + setIsPasswordPrompt(true); if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; @@ -606,27 +693,66 @@ export const Terminal = forwardRef( useEffect(() => { if (!terminal || !xtermRef.current) return; + // Merge default config with host-specific config + const config = { + ...DEFAULT_TERMINAL_CONFIG, + ...hostConfig.terminalConfig, + }; + + // Get theme colors + const themeColors = + TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors; + + // Get font family with fallback + const fontConfig = TERMINAL_FONTS.find( + (f) => f.value === config.fontFamily, + ); + const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback; + terminal.options = { - cursorBlink: true, - cursorStyle: "bar", - scrollback: 10000, - fontSize: 14, - fontFamily: - '"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace', + cursorBlink: config.cursorBlink, + cursorStyle: config.cursorStyle, + scrollback: config.scrollback, + fontSize: config.fontSize, + fontFamily, allowTransparency: true, convertEol: true, windowsMode: false, macOptionIsMeta: false, macOptionClickForcesSelection: false, - rightClickSelectsWord: false, - fastScrollModifier: "alt", - fastScrollSensitivity: 5, + rightClickSelectsWord: config.rightClickSelectsWord, + fastScrollModifier: config.fastScrollModifier, + fastScrollSensitivity: config.fastScrollSensitivity, allowProposedApi: true, - minimumContrastRatio: 1, - letterSpacing: 0, - lineHeight: 1.2, + minimumContrastRatio: config.minimumContrastRatio, + letterSpacing: config.letterSpacing, + lineHeight: config.lineHeight, + bellStyle: config.bellStyle as "none" | "sound", - theme: { background: "#18181b", foreground: "#f7f7f7" }, + theme: { + background: themeColors.background, + foreground: themeColors.foreground, + cursor: themeColors.cursor, + cursorAccent: themeColors.cursorAccent, + selectionBackground: themeColors.selectionBackground, + selectionForeground: themeColors.selectionForeground, + black: themeColors.black, + red: themeColors.red, + green: themeColors.green, + yellow: themeColors.yellow, + blue: themeColors.blue, + magenta: themeColors.magenta, + cyan: themeColors.cyan, + white: themeColors.white, + brightBlack: themeColors.brightBlack, + brightRed: themeColors.brightRed, + brightGreen: themeColors.brightGreen, + brightYellow: themeColors.brightYellow, + brightBlue: themeColors.brightBlue, + brightMagenta: themeColors.brightMagenta, + brightCyan: themeColors.brightCyan, + brightWhite: themeColors.brightWhite, + }, }; const fitAddon = new FitAddon(); @@ -671,6 +797,24 @@ export const Terminal = forwardRef( navigator.platform.toUpperCase().indexOf("MAC") >= 0 || navigator.userAgent.toUpperCase().indexOf("MAC") >= 0; + // Handle backspace mode (Control-H) + if ( + config.backspaceMode === "control-h" && + e.key === "Backspace" && + !e.ctrlKey && + !e.metaKey && + !e.altKey + ) { + e.preventDefault(); + e.stopPropagation(); + if (webSocketRef.current?.readyState === 1) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: "\x08" }), + ); + } + return false; + } + if (!isMacOS) return; if (e.altKey && !e.metaKey && !e.ctrlKey) { @@ -739,7 +883,7 @@ export const Terminal = forwardRef( } webSocketRef.current?.close(); }; - }, [xtermRef, terminal]); + }, [xtermRef, terminal, hostConfig]); useEffect(() => { if (!terminal || !hostConfig || !visible) return; @@ -813,7 +957,7 @@ export const Terminal = forwardRef( }, [splitScreen, isVisible, terminal]); return ( -
+
( prompt={totpPrompt} onSubmit={handleTotpSubmit} onCancel={handleTotpCancel} + backgroundColor={backgroundColor} /> {isConnecting && ( -
+
{t("terminal.connecting")} @@ -846,6 +994,11 @@ export const Terminal = forwardRef( const style = document.createElement("style"); style.innerHTML = ` +/* Import popular terminal fonts from Google Fonts */ +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap'); + @font-face { font-family: 'Caskaydia Cove Nerd Font Mono'; src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype'); diff --git a/src/ui/Desktop/Authentication/Auth.tsx b/src/ui/Desktop/Authentication/Auth.tsx index 920948c9..55f11269 100644 --- a/src/ui/Desktop/Authentication/Auth.tsx +++ b/src/ui/Desktop/Authentication/Auth.tsx @@ -722,364 +722,412 @@ export function Auth({ {!loggedIn && !authLoading && !totpRequired && ( <> -
- {passwordLoginAllowed && ( - - )} - {passwordLoginAllowed && ( - - )} - {oidcConfigured && ( - - )} -
-
-

- {tab === "login" - ? t("auth.loginTitle") - : tab === "signup" - ? t("auth.registerTitle") - : tab === "external" - ? t("auth.loginWithExternal") - : t("auth.forgotPassword")} -

-
+ {(() => { + // Check if any authentication method is available + const hasLogin = passwordLoginAllowed && !firstUser; + const hasSignup = + (passwordLoginAllowed || firstUser) && registrationAllowed; + const hasOIDC = oidcConfigured; + const hasAnyAuth = hasLogin || hasSignup || hasOIDC; - {tab === "external" || tab === "reset" ? ( -
- {tab === "external" && ( - <> -
-

{t("auth.loginWithExternalDesc")}

-
- {(() => { - if (isElectron()) { - return ( -
-

- {t("auth.externalNotSupportedInElectron")} -

-
- ); - } else { - return ( - - ); - } - })()} - - )} - {tab === "reset" && ( - <> - {resetStep === "initiate" && ( - <> -
-

{t("auth.resetCodeDesc")}

-
-
-
- - setLocalUsername(e.target.value)} - disabled={resetLoading} - /> -
- -
- + if (!hasAnyAuth) { + return ( +
+

+ {t("auth.authenticationDisabled")} +

+

+ {t("auth.authenticationDisabledDesc")} +

+
+ ); + } + + return ( + <> +
+ {passwordLoginAllowed && ( + )} - - {resetStep === "verify" && ( - <> -
-

- {t("auth.enterResetCode")}{" "} - {localUsername} -

-
-
-
- - - setResetCode(e.target.value.replace(/\D/g, "")) - } - disabled={resetLoading} - placeholder="000000" - /> -
- - -
- + {(passwordLoginAllowed || firstUser) && + registrationAllowed && ( + + )} + {oidcConfigured && ( + )} +
+
+

+ {tab === "login" + ? t("auth.loginTitle") + : tab === "signup" + ? t("auth.registerTitle") + : tab === "external" + ? t("auth.loginWithExternal") + : t("auth.forgotPassword")} +

+
- {resetStep === "newPassword" && !resetSuccess && ( - <> -
-

- {t("auth.enterNewPassword")}{" "} - {localUsername} -

-
-
-
- - setNewPassword(e.target.value)} - disabled={resetLoading} - autoComplete="new-password" - /> + {tab === "external" || tab === "reset" ? ( +
+ {tab === "external" && ( + <> +
+

{t("auth.loginWithExternalDesc")}

-
- - setConfirmPassword(e.target.value)} - disabled={resetLoading} - autoComplete="new-password" - /> -
- + ); } - onClick={handleCompletePasswordReset} - > - {resetLoading - ? Spinner - : t("auth.resetPasswordButton")} - - -
- - )} - - )} -
- ) : ( -
-
- - setLocalUsername(e.target.value)} - disabled={loading || loggedIn} - /> -
-
- - setPassword(e.target.value)} - disabled={loading || loggedIn} - /> -
- {tab === "signup" && ( -
- - setSignupConfirmPassword(e.target.value)} - disabled={loading || loggedIn} - /> -
- )} - - {tab === "login" && ( - - )} -
- )} + })()} + + )} + {tab === "reset" && ( + <> + {resetStep === "initiate" && ( + <> +
+

{t("auth.resetCodeDesc")}

+
+
+
+ + + setLocalUsername(e.target.value) + } + disabled={resetLoading} + /> +
+ +
+ + )} -
-
-
- -
- -
- {isElectron() && currentServerUrl && ( -
-
- -
- {currentServerUrl} + {resetStep === "verify" && ( + <> +
+

+ {t("auth.enterResetCode")}{" "} + {localUsername} +

+
+
+
+ + + setResetCode( + e.target.value.replace(/\D/g, ""), + ) + } + disabled={resetLoading} + placeholder="000000" + /> +
+ + +
+ + )} + + {resetStep === "newPassword" && !resetSuccess && ( + <> +
+

+ {t("auth.enterNewPassword")}{" "} + {localUsername} +

+
+
+
+ + + setNewPassword(e.target.value) + } + disabled={resetLoading} + autoComplete="new-password" + /> +
+
+ + + setConfirmPassword(e.target.value) + } + disabled={resetLoading} + autoComplete="new-password" + /> +
+ + +
+ + )} + + )}
+ ) : ( +
+
+ + setLocalUsername(e.target.value)} + disabled={loading || loggedIn} + /> +
+
+ + setPassword(e.target.value)} + disabled={loading || loggedIn} + /> +
+ {tab === "signup" && ( +
+ + + setSignupConfirmPassword(e.target.value) + } + disabled={loading || loggedIn} + /> +
+ )} + + {tab === "login" && ( + + )} +
+ )} + +
+
+
+ +
+ +
+ {isElectron() && currentServerUrl && ( +
+
+ +
+ {currentServerUrl} +
+
+ +
+ )}
- -
- )} -
+ + ); + })()} )}
diff --git a/src/ui/Desktop/Navigation/AppView.tsx b/src/ui/Desktop/Navigation/AppView.tsx index 6a40a5cd..322fe326 100644 --- a/src/ui/Desktop/Navigation/AppView.tsx +++ b/src/ui/Desktop/Navigation/AppView.tsx @@ -12,6 +12,10 @@ import * as ResizablePrimitive from "react-resizable-panels"; import { useSidebar } from "@/components/ui/sidebar.tsx"; import { RefreshCcw } from "lucide-react"; import { Button } from "@/components/ui/button.tsx"; +import { + TERMINAL_THEMES, + DEFAULT_TERMINAL_CONFIG, +} from "@/constants/terminal-themes"; interface TabData { id: number; @@ -24,7 +28,7 @@ interface TabData { refresh?: () => void; }; }; - hostConfig?: unknown; + hostConfig?: any; [key: string]: unknown; } @@ -258,9 +262,24 @@ export function AppView({ const effectiveVisible = isVisible && ready; + const isTerminal = t.type === "terminal"; + const terminalConfig = { + ...DEFAULT_TERMINAL_CONFIG, + ...(t.hostConfig as any)?.terminalConfig, + }; + const themeColors = + TERMINAL_THEMES[terminalConfig.theme]?.colors || + TERMINAL_THEMES.termix.colors; + const backgroundColor = themeColors.background; + return (
-
+
{t.type === "terminal" ? ( tab.id === currentTab); const isFileManager = currentTabData?.type === "file_manager"; + const isTerminal = currentTabData?.type === "terminal"; const isSplitScreen = allSplitScreenTab.length > 0; + // Get terminal background color for the current tab + const terminalConfig = { + ...DEFAULT_TERMINAL_CONFIG, + ...(currentTabData?.hostConfig as any)?.terminalConfig, + }; + const themeColors = + TERMINAL_THEMES[terminalConfig.theme]?.colors || + TERMINAL_THEMES.termix.colors; + const terminalBackgroundColor = themeColors.background; + const topMarginPx = isTopbarOpen ? 74 : 26; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; const bottomMarginPx = 8; + // Determine background color based on current tab type + let containerBackground = "var(--color-dark-bg)"; + if (isFileManager && !isSplitScreen) { + containerBackground = "var(--color-dark-bg-darkest)"; + } else if (isTerminal) { + containerBackground = terminalBackgroundColor; + } + return (
-
- {passwordLoginAllowed && ( - - )} - {passwordLoginAllowed && ( - - )} - {oidcConfigured && ( - - )} -
-
-

- {tab === "login" - ? t("auth.loginTitle") - : tab === "signup" - ? t("auth.registerTitle") - : tab === "external" - ? t("auth.loginWithExternal") - : t("auth.forgotPassword")} -

-
+ {(() => { + // Check if any authentication method is available + const hasLogin = passwordLoginAllowed && !firstUser; + const hasSignup = + (passwordLoginAllowed || firstUser) && registrationAllowed; + const hasOIDC = oidcConfigured; + const hasAnyAuth = hasLogin || hasSignup || hasOIDC; - {tab === "external" || tab === "reset" ? ( -
- {tab === "external" && ( - <> -
-

{t("auth.loginWithExternalDesc")}

+ if (!hasAnyAuth) { + return ( +
+

+ {t("auth.authenticationDisabled")} +

+

+ {t("auth.authenticationDisabledDesc")} +

+
+ ); + } + + return ( + <> +
+ {passwordLoginAllowed && ( + + )} + {(passwordLoginAllowed || firstUser) && + registrationAllowed && ( + + )} + {oidcConfigured && ( + + )} +
+
+

+ {tab === "login" + ? t("auth.loginTitle") + : tab === "signup" + ? t("auth.registerTitle") + : tab === "external" + ? t("auth.loginWithExternal") + : t("auth.forgotPassword")} +

+
+ + {tab === "external" || tab === "reset" ? ( +
+ {tab === "external" && ( + <> +
+

{t("auth.loginWithExternalDesc")}

+
+ + + )} + {tab === "reset" && ( + <> + {resetStep === "initiate" && ( + <> +
+

{t("auth.resetCodeDesc")}

+
+
+
+ + + setLocalUsername(e.target.value) + } + disabled={resetLoading} + /> +
+ +
+ + )} + + {resetStep === "verify" && ( + <> +
+

+ {t("auth.enterResetCode")}{" "} + {localUsername} +

+
+
+
+ + + setResetCode( + e.target.value.replace(/\D/g, ""), + ) + } + disabled={resetLoading} + placeholder="000000" + /> +
+ + +
+ + )} + + {resetStep === "newPassword" && !resetSuccess && ( + <> +
+

+ {t("auth.enterNewPassword")}{" "} + {localUsername} +

+
+
+
+ + + setNewPassword(e.target.value) + } + disabled={resetLoading} + autoComplete="new-password" + /> +
+
+ + + setConfirmPassword(e.target.value) + } + disabled={resetLoading} + autoComplete="new-password" + /> +
+ + +
+ + )} + + )}
+ ) : ( +
+
+ + setLocalUsername(e.target.value)} + disabled={loading || internalLoggedIn} + /> +
+
+ + setPassword(e.target.value)} + disabled={loading || internalLoggedIn} + /> +
+ {tab === "signup" && ( +
+ + + setSignupConfirmPassword(e.target.value) + } + disabled={loading || internalLoggedIn} + /> +
+ )} + + {tab === "login" && ( + + )} +
+ )} + +
+
+
+ +
+ +
+
+ +
- - )} - {tab === "reset" && ( - <> - {resetStep === "initiate" && ( - <> -
-

{t("auth.resetCodeDesc")}

-
-
-
- - setLocalUsername(e.target.value)} - disabled={resetLoading} - /> -
- -
- - )} - - {resetStep === "verify" && ( - <> -
-

- {t("auth.enterResetCode")}{" "} - {localUsername} -

-
-
-
- - - setResetCode(e.target.value.replace(/\D/g, "")) - } - disabled={resetLoading} - placeholder="000000" - /> -
- - -
- - )} - - {resetStep === "newPassword" && !resetSuccess && ( - <> -
-

- {t("auth.enterNewPassword")}{" "} - {localUsername} -

-
-
-
- - setNewPassword(e.target.value)} - disabled={resetLoading} - autoComplete="new-password" - /> -
-
- - setConfirmPassword(e.target.value)} - disabled={resetLoading} - autoComplete="new-password" - /> -
- - -
- - )} - - )} -
- ) : ( -
-
- - setLocalUsername(e.target.value)} - disabled={loading || internalLoggedIn} - /> -
-
- - setPassword(e.target.value)} - disabled={loading || internalLoggedIn} - /> -
- {tab === "signup" && ( -
- - setSignupConfirmPassword(e.target.value)} - disabled={loading || internalLoggedIn} - />
- )} - - {tab === "login" && ( - - )} -
- )} - -
-
-
- -
- -
-
- -
- -
+ + ); + })()} )}
diff --git a/src/ui/components/TOTPDialog.tsx b/src/ui/components/TOTPDialog.tsx index 3fb191c9..e33b725e 100644 --- a/src/ui/components/TOTPDialog.tsx +++ b/src/ui/components/TOTPDialog.tsx @@ -10,6 +10,7 @@ interface TOTPDialogProps { prompt: string; onSubmit: (code: string) => void; onCancel: () => void; + backgroundColor?: string; } export function TOTPDialog({ @@ -17,6 +18,7 @@ export function TOTPDialog({ prompt, onSubmit, onCancel, + backgroundColor, }: TOTPDialogProps) { const { t } = useTranslation(); @@ -24,7 +26,10 @@ export function TOTPDialog({ return (
-
+
diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index d45a31d7..03cceec8 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -747,6 +747,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise { defaultPath: hostData.defaultPath || "/", tunnelConnections: hostData.tunnelConnections || [], statsConfig: hostData.statsConfig || null, + terminalConfig: hostData.terminalConfig || null, }; if (!submitData.enableTunnel) { @@ -804,6 +805,7 @@ export async function updateSSHHost( defaultPath: hostData.defaultPath || "/", tunnelConnections: hostData.tunnelConnections || [], statsConfig: hostData.statsConfig || null, + terminalConfig: hostData.terminalConfig || null, }; if (!submitData.enableTunnel) {