diff --git a/src/main/ipc/sshIpc.ts b/src/main/ipc/sshIpc.ts index 1cd643e95..4540d1acd 100644 --- a/src/main/ipc/sshIpc.ts +++ b/src/main/ipc/sshIpc.ts @@ -1,4 +1,5 @@ import { ipcMain } from 'electron'; +import { execFile } from 'child_process'; import { SSH_IPC_CHANNELS } from '../../shared/ssh/types'; import { sshService } from '../services/ssh/SshService'; import { SshCredentialService } from '../services/ssh/SshCredentialService'; @@ -50,7 +51,7 @@ function mapRowToConfig(row: { host: row.host, port: row.port, username: row.username, - authType: row.authType as 'password' | 'key' | 'agent', + authType: row.authType as 'password' | 'key' | 'agent' | 'gssapi', privateKeyPath: row.privateKeyPath ?? undefined, useAgent: row.useAgent === 1, }; @@ -161,6 +162,61 @@ export function registerSshIpc() { config: SshConfig & { password?: string; passphrase?: string } ): Promise => { try { + // GSSAPI connections use system ssh for testing (ssh2 doesn't support GSSAPI) + if (config.authType === 'gssapi') { + return new Promise((resolve) => { + const startTime = Date.now(); + const debugLogs: string[] = []; + + const sshArgs = [ + '-o', + 'GSSAPIAuthentication=yes', + '-o', + 'PreferredAuthentications=gssapi-with-mic,gssapi-keyex', + '-o', + 'StrictHostKeyChecking=accept-new', + '-o', + 'BatchMode=yes', + '-o', + 'ConnectTimeout=10', + '-v', // Verbose for debug logs + '-p', + String(config.port), + '-l', + config.username, + config.host, + 'echo __EMDASH_SSH_OK__', + ]; + + execFile( + 'ssh', + sshArgs, + { + timeout: 15000, + env: { + ...process.env, + KRB5CCNAME: process.env.KRB5CCNAME || '', + }, + }, + (err, stdout, stderr) => { + const latency = Date.now() - startTime; + + // Capture verbose ssh output as debug logs + if (stderr) { + debugLogs.push(...stderr.split('\n').filter(Boolean)); + } + + if (stdout && stdout.includes('__EMDASH_SSH_OK__')) { + resolve({ success: true, latency, debugLogs }); + } else { + const errorMsg = err?.message || stderr?.trim() || 'GSSAPI authentication failed'; + resolve({ success: false, error: errorMsg, debugLogs }); + } + } + ); + }); + } + const { Client } = await import('ssh2'); const debugLogs: string[] = []; const testClient = new Client(); @@ -571,6 +627,43 @@ export function registerSshIpc() { return { success: false, error: 'Access denied: path is restricted' }; } + // GSSAPI connections use command-based file operations + if (sshService.isGssapiConnection(connectionId)) { + const result = await sshService.executeCommand( + connectionId, + `ls -la --time-style=+%s ${quoteShellArg(path)} 2>/dev/null` + ); + if (result.exitCode !== 0) { + return { success: false, error: `Failed to list files: ${result.stderr}` }; + } + + const entries: FileEntry[] = []; + for (const line of result.stdout.split('\n')) { + // Parse ls -la output: permissions links owner group size timestamp name + const match = line.match( + /^([dlscp-])([rwxsStT-]{9})\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\d+)\s+(.+)$/ + ); + if (!match) continue; + const [, typeChar, perms, sizeStr, mtimeStr, name] = match; + if (name === '.' || name === '..') continue; + + let type: 'file' | 'directory' | 'symlink' = 'file'; + if (typeChar === 'd') type = 'directory'; + else if (typeChar === 'l') type = 'symlink'; + + entries.push({ + path: `${path}/${name}`.replace(/\/+/g, '/'), + name, + type, + size: parseInt(sizeStr, 10), + modifiedAt: new Date(parseInt(mtimeStr, 10) * 1000), + permissions: perms, + }); + } + + return { success: true, files: entries }; + } + const sftp = await sshService.getSftp(connectionId); return new Promise((resolve) => { @@ -622,6 +715,18 @@ export function registerSshIpc() { return { success: false, error: 'Access denied: path is restricted' }; } + // GSSAPI connections use command-based file operations + if (sshService.isGssapiConnection(connectionId)) { + const result = await sshService.executeCommand( + connectionId, + `cat ${quoteShellArg(path)}` + ); + if (result.exitCode !== 0) { + return { success: false, error: `Failed to read file: ${result.stderr}` }; + } + return { success: true, content: result.stdout }; + } + const sftp = await sshService.getSftp(connectionId); return new Promise((resolve) => { @@ -655,6 +760,19 @@ export function registerSshIpc() { return { success: false, error: 'Access denied: path is restricted' }; } + // GSSAPI connections use command-based file operations + if (sshService.isGssapiConnection(connectionId)) { + const encoded = Buffer.from(content, 'utf-8').toString('base64'); + const result = await sshService.executeCommand( + connectionId, + `echo ${quoteShellArg(encoded)} | base64 -d > ${quoteShellArg(path)}` + ); + if (result.exitCode !== 0) { + return { success: false, error: `Failed to write file: ${result.stderr}` }; + } + return { success: true }; + } + const sftp = await sshService.getSftp(connectionId); return new Promise((resolve) => { diff --git a/src/main/services/RemotePtyService.ts b/src/main/services/RemotePtyService.ts index 854f25c8c..d3d8e5d8e 100644 --- a/src/main/services/RemotePtyService.ts +++ b/src/main/services/RemotePtyService.ts @@ -63,6 +63,36 @@ export class RemotePtyService extends EventEmitter { * @throws Error if connection not found or shell creation fails */ async startRemotePty(options: RemotePtyOptions): Promise { + // Build the remote command (shared between ssh2 and GSSAPI paths) + const envEntries = Object.entries(options.env || {}).filter(([k]) => { + if (!isValidEnvVarName(k)) { + console.warn(`[RemotePtyService] Skipping invalid env var name: ${k}`); + return false; + } + return true; + }); + const envVars = envEntries.map(([k, v]) => `export ${k}=${quoteShellArg(v)}`).join(' && '); + const cdCommand = options.cwd ? `cd ${quoteShellArg(options.cwd)}` : ''; + const autoApproveFlag = options.autoApprove ? ' --full-auto' : ''; + + // Validate shell against allowlist (HIGH #5) + const shellBinary = options.shell.split(/\s+/)[0]; + if (!ALLOWED_SHELLS.has(shellBinary)) { + throw new Error( + `Shell not allowed: ${shellBinary}. Allowed: ${[...ALLOWED_SHELLS].join(', ')}` + ); + } + + const fullCommand = [envVars, cdCommand, `${options.shell}${autoApproveFlag}`] + .filter(Boolean) + .join(' && '); + + // GSSAPI connections: spawn system ssh with ControlMaster socket + if (this.sshService.isGssapiConnection(options.connectionId)) { + return this.startGssapiPty(options, fullCommand); + } + + // Standard ssh2 connections const connection = this.sshService.getConnection(options.connectionId); if (!connection) { throw new Error(`Connection ${options.connectionId} not found`); @@ -77,35 +107,6 @@ export class RemotePtyService extends EventEmitter { return; } - // Build command with environment and cwd - // Validate env var keys to prevent injection (CRITICAL #1) - const envEntries = Object.entries(options.env || {}).filter(([k]) => { - if (!isValidEnvVarName(k)) { - console.warn(`[RemotePtyService] Skipping invalid env var name: ${k}`); - return false; - } - return true; - }); - const envVars = envEntries.map(([k, v]) => `export ${k}=${quoteShellArg(v)}`).join(' && '); - - const cdCommand = options.cwd ? `cd ${quoteShellArg(options.cwd)}` : ''; - const autoApproveFlag = options.autoApprove ? ' --full-auto' : ''; - - // Validate shell against allowlist (HIGH #5) - const shellBinary = options.shell.split(/\s+/)[0]; - if (!ALLOWED_SHELLS.has(shellBinary)) { - reject( - new Error( - `Shell not allowed: ${shellBinary}. Allowed: ${[...ALLOWED_SHELLS].join(', ')}` - ) - ); - return; - } - - const fullCommand = [envVars, cdCommand, `${options.shell}${autoApproveFlag}`] - .filter(Boolean) - .join(' && '); - // Send initial command stream.write(fullCommand + '\n'); @@ -138,6 +139,64 @@ export class RemotePtyService extends EventEmitter { }); } + /** + * Starts a remote PTY session for GSSAPI connections using system ssh with ControlMaster. + */ + private async startGssapiPty( + options: RemotePtyOptions, + remoteCommand: string + ): Promise { + const sshArgs = this.sshService.getGssapiSshArgs(options.connectionId); + if (!sshArgs) { + throw new Error(`GSSAPI connection ${options.connectionId} not found`); + } + + // Use node-pty to spawn ssh -tt with the ControlMaster socket + let pty: typeof import('node-pty'); + try { + pty = require('node-pty'); + } catch (e: any) { + throw new Error(`PTY unavailable: ${e?.message || String(e)}`); + } + + const proc = pty.spawn('ssh', ['-tt', ...sshArgs, remoteCommand], { + name: 'xterm-256color', + cols: 120, + rows: 32, + env: { + TERM: 'xterm-256color', + HOME: process.env.HOME || require('os').homedir(), + PATH: process.env.PATH || '', + KRB5CCNAME: process.env.KRB5CCNAME || '', + }, + }); + + // Send initial prompt if provided + if (options.initialPrompt) { + setTimeout(() => { + proc.write(options.initialPrompt + '\n'); + }, 500); + } + + const remotePty: RemotePty = { + id: options.id, + write: (data: string) => proc.write(data), + resize: (cols: number, rows: number) => proc.resize(cols, rows), + kill: () => proc.kill(), + onData: (callback) => proc.onData(callback), + onExit: (callback) => proc.onExit(({ exitCode }) => callback(exitCode)), + }; + + this.ptys.set(options.id, remotePty); + + proc.onExit(() => { + this.ptys.delete(options.id); + this.emit('exit', options.id); + }); + + return remotePty; + } + /** * Writes data to a remote PTY session. * diff --git a/src/main/services/ssh/SshService.ts b/src/main/services/ssh/SshService.ts index 60ccd1f24..128589c02 100644 --- a/src/main/services/ssh/SshService.ts +++ b/src/main/services/ssh/SshService.ts @@ -1,12 +1,15 @@ import { EventEmitter } from 'events'; import { Client, SFTPWrapper, ConnectConfig } from 'ssh2'; import { SshConfig, ExecResult } from '../../../shared/ssh/types'; -import { Connection, ConnectionPool } from './types'; +import { Connection, ConnectionPool, GssapiConnection } from './types'; import { SshCredentialService } from './SshCredentialService'; import { quoteShellArg } from '../../utils/shellEscape'; import { readFile } from 'fs/promises'; import { randomUUID } from 'crypto'; -import { homedir } from 'os'; +import { homedir, tmpdir } from 'os'; +import { join } from 'path'; +import { execFile, spawn, ChildProcess } from 'child_process'; +import { access, unlink } from 'fs/promises'; import { resolveIdentityAgent } from '../../utils/sshConfigParser'; /** Maximum number of concurrent SSH connections allowed in the pool. */ @@ -26,6 +29,8 @@ const POOL_WARNING_THRESHOLD = 0.8; */ export class SshService extends EventEmitter { private connections: ConnectionPool = {}; + private gssapiConnections: Map = new Map(); + private gssapiProcesses: Map = new Map(); private pendingConnections: Map> = new Map(); private credentialService: SshCredentialService; @@ -50,7 +55,7 @@ export class SshService extends EventEmitter { const connectionId = config.id ?? randomUUID(); // 1. If already connected, reuse the existing connection - if (this.connections[connectionId]) { + if (this.connections[connectionId] || this.gssapiConnections.has(connectionId)) { return connectionId; } @@ -61,7 +66,10 @@ export class SshService extends EventEmitter { } // 3. Enforce connection pool limit - const poolSize = Object.keys(this.connections).length + this.pendingConnections.size; + const poolSize = + Object.keys(this.connections).length + + this.gssapiConnections.size + + this.pendingConnections.size; if (poolSize >= MAX_CONNECTIONS) { throw new Error( `SSH connection pool limit reached (${MAX_CONNECTIONS}). ` + @@ -74,7 +82,18 @@ export class SshService extends EventEmitter { ); } - // 4. Create the connection and track the in-flight promise + // 4. GSSAPI uses system ssh with ControlMaster instead of ssh2 + if (config.authType === 'gssapi') { + const connectionPromise = this.connectGssapi(connectionId, config); + this.pendingConnections.set(connectionId, connectionPromise); + try { + return await connectionPromise; + } finally { + this.pendingConnections.delete(connectionId); + } + } + + // 5. Create the ssh2 connection and track the in-flight promise const connectionPromise = this.createConnection(connectionId, config); this.pendingConnections.set(connectionId, connectionPromise); @@ -227,11 +246,221 @@ export class SshService extends EventEmitter { return connectConfig; } + /** + * Establishes a GSSAPI/Kerberos SSH connection using system ssh with ControlMaster. + * Since the ssh2 library doesn't support GSSAPI authentication, we use the system's + * OpenSSH client which has native Kerberos support. + */ + private async connectGssapi(connectionId: string, config: SshConfig): Promise { + const socketPath = join(tmpdir(), `emdash-ssh-${connectionId}`); + + // Clean up any stale socket file + try { + await unlink(socketPath); + } catch { + // Ignore if doesn't exist + } + + const sshArgs = [ + '-f', // Go to background after auth + '-N', // No remote command + '-M', // ControlMaster mode + '-S', + socketPath, + '-o', + 'GSSAPIAuthentication=yes', + '-o', + 'GSSAPIDelegateCredentials=yes', + '-o', + 'PreferredAuthentications=gssapi-with-mic,gssapi-keyex', + '-o', + 'StrictHostKeyChecking=accept-new', + '-o', + 'BatchMode=yes', + '-o', + 'ConnectTimeout=15', + '-p', + String(config.port), + '-l', + config.username, + config.host, + ]; + + return new Promise((resolve, reject) => { + const proc = spawn('ssh', sshArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + // Ensure Kerberos ticket cache is available + KRB5CCNAME: process.env.KRB5CCNAME || '', + }, + }); + + let stderr = ''; + proc.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + // ssh -f will exit after backgrounding if auth succeeds. + // If it exits with code 0, the ControlMaster is running. + proc.on('close', (code) => { + if (code === 0) { + const gssapiConn: GssapiConnection = { + id: connectionId, + config, + controlSocketPath: socketPath, + connectedAt: new Date(), + lastActivity: new Date(), + }; + this.gssapiConnections.set(connectionId, gssapiConn); + this.emit('connected', connectionId); + resolve(connectionId); + } else { + const errorMsg = stderr.trim() || `SSH GSSAPI authentication failed (exit code ${code})`; + reject(new Error(errorMsg)); + } + }); + + proc.on('error', (err) => { + reject(new Error(`Failed to spawn ssh: ${err.message}`)); + }); + + // Store reference for cleanup + this.gssapiProcesses.set(connectionId, proc); + }); + } + + /** + * Checks if a connection is using GSSAPI/Kerberos authentication. + */ + isGssapiConnection(connectionId: string): boolean { + return this.gssapiConnections.has(connectionId); + } + + /** + * Gets the GSSAPI connection info (including ControlMaster socket path). + */ + getGssapiConnection(connectionId: string): GssapiConnection | undefined { + return this.gssapiConnections.get(connectionId); + } + + /** + * Builds SSH args for GSSAPI ControlMaster connection reuse. + */ + getGssapiSshArgs(connectionId: string): string[] | undefined { + const conn = this.gssapiConnections.get(connectionId); + if (!conn) return undefined; + return [ + '-S', + conn.controlSocketPath, + '-o', + 'ControlMaster=no', + '-p', + String(conn.config.port), + '-l', + conn.config.username, + conn.config.host, + ]; + } + + /** + * Executes a command on a GSSAPI connection using the ControlMaster socket. + */ + private executeCommandGssapi( + conn: GssapiConnection, + command: string, + cwd?: string + ): Promise { + const innerCommand = cwd ? `cd ${quoteShellArg(cwd)} && ${command}` : command; + const fullCommand = `bash -l -c ${quoteShellArg(innerCommand)}`; + + return new Promise((resolve, reject) => { + execFile( + 'ssh', + [ + '-S', + conn.controlSocketPath, + '-o', + 'ControlMaster=no', + '-p', + String(conn.config.port), + '-l', + conn.config.username, + conn.config.host, + fullCommand, + ], + { timeout: 30000, maxBuffer: 10 * 1024 * 1024 }, + (err, stdout, stderr) => { + if (err && 'code' in err && typeof (err as any).code === 'number') { + // Command exited with non-zero code, still a valid result + resolve({ + stdout: (stdout || '').trim(), + stderr: (stderr || '').trim(), + exitCode: (err as any).code as number, + }); + } else if (err) { + reject(err); + } else { + resolve({ + stdout: (stdout || '').trim(), + stderr: (stderr || '').trim(), + exitCode: 0, + }); + } + } + ); + }); + } + + /** + * Disconnects a GSSAPI connection by terminating the ControlMaster. + */ + private async disconnectGssapi(connectionId: string): Promise { + const conn = this.gssapiConnections.get(connectionId); + if (!conn) return; + + // Send exit command to ControlMaster + try { + await new Promise((resolve) => { + execFile( + 'ssh', + ['-S', conn.controlSocketPath, '-O', 'exit', conn.config.host], + { timeout: 5000 }, + () => resolve() // Ignore errors, best-effort + ); + }); + } catch { + // Best-effort cleanup + } + + // Clean up socket file + try { + await unlink(conn.controlSocketPath); + } catch { + // Ignore + } + + // Kill any lingering process + const proc = this.gssapiProcesses.get(connectionId); + if (proc && !proc.killed) { + proc.kill(); + } + + this.gssapiProcesses.delete(connectionId); + this.gssapiConnections.delete(connectionId); + this.emit('disconnected', connectionId); + } + /** * Disconnects an existing SSH connection. * @param connectionId - ID of the connection to close */ async disconnect(connectionId: string): Promise { + // Handle GSSAPI connections + if (this.gssapiConnections.has(connectionId)) { + return this.disconnectGssapi(connectionId); + } + const connection = this.connections[connectionId]; if (!connection) { return; // Already disconnected or never existed @@ -273,6 +502,13 @@ export class SshService extends EventEmitter { * @returns Command execution result */ async executeCommand(connectionId: string, command: string, cwd?: string): Promise { + // Handle GSSAPI connections + const gssapiConn = this.gssapiConnections.get(connectionId); + if (gssapiConn) { + gssapiConn.lastActivity = new Date(); + return this.executeCommandGssapi(gssapiConn, command, cwd); + } + const connection = this.connections[connectionId]; if (!connection) { throw new Error(`Connection ${connectionId} not found`); @@ -328,6 +564,12 @@ export class SshService extends EventEmitter { * @returns SFTP wrapper instance */ async getSftp(connectionId: string): Promise { + if (this.gssapiConnections.has(connectionId)) { + throw new Error( + 'SFTP is not available for GSSAPI connections. Use executeCommand-based file operations instead.' + ); + } + const connection = this.connections[connectionId]; if (!connection) { throw new Error(`Connection ${connectionId} not found`); @@ -377,7 +619,7 @@ export class SshService extends EventEmitter { * @returns True if connected */ isConnected(connectionId: string): boolean { - return connectionId in this.connections; + return connectionId in this.connections || this.gssapiConnections.has(connectionId); } /** @@ -385,7 +627,7 @@ export class SshService extends EventEmitter { * @returns Array of connection IDs */ listConnections(): string[] { - return Object.keys(this.connections); + return [...Object.keys(this.connections), ...this.gssapiConnections.keys()]; } /** @@ -394,11 +636,14 @@ export class SshService extends EventEmitter { */ getConnectionInfo(connectionId: string): { connectedAt: Date; lastActivity: Date } | null { const conn = this.connections[connectionId]; - if (!conn) return null; - return { - connectedAt: conn.connectedAt, - lastActivity: conn.lastActivity, - }; + if (conn) { + return { connectedAt: conn.connectedAt, lastActivity: conn.lastActivity }; + } + const gssapiConn = this.gssapiConnections.get(connectionId); + if (gssapiConn) { + return { connectedAt: gssapiConn.connectedAt, lastActivity: gssapiConn.lastActivity }; + } + return null; } /** @@ -406,7 +651,8 @@ export class SshService extends EventEmitter { * Useful for cleanup on shutdown. */ async disconnectAll(): Promise { - const disconnectPromises = Object.keys(this.connections).map((id) => + const allIds = [...Object.keys(this.connections), ...this.gssapiConnections.keys()]; + const disconnectPromises = allIds.map((id) => this.disconnect(id).catch(() => { // Ignore errors during bulk disconnect }) diff --git a/src/main/services/ssh/__tests__/SshService.test.ts b/src/main/services/ssh/__tests__/SshService.test.ts index 4375ed956..ce1b85b81 100644 --- a/src/main/services/ssh/__tests__/SshService.test.ts +++ b/src/main/services/ssh/__tests__/SshService.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { spawn, execFile } from 'child_process'; import { SshService } from '../SshService'; import { SshCredentialService } from '../SshCredentialService'; import { SshConfig } from '../../../../shared/ssh/types'; +const mockSpawn = spawn as unknown as Mock; +const mockExecFile = execFile as unknown as Mock; + // Mock ssh2 Client const mockClientInstance = { on: vi.fn(), @@ -19,6 +23,8 @@ vi.mock('ssh2', () => ({ // Mock fs/promises vi.mock('fs/promises', () => ({ readFile: vi.fn(), + unlink: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), })); // Mock crypto @@ -26,6 +32,12 @@ vi.mock('crypto', () => ({ randomUUID: vi.fn().mockReturnValue('test-uuid-123'), })); +// Mock child_process +vi.mock('child_process', () => ({ + spawn: vi.fn(), + execFile: vi.fn(), +})); + // Prevent keytar/native module loading through SshService's module-level singleton. vi.mock('../SshCredentialService', () => ({ SshCredentialService: class MockSshCredentialService { @@ -443,6 +455,161 @@ describe('SshService', () => { }); }); + describe('GSSAPI/Kerberos authentication', () => { + /** + * Helper: sets up mockSpawn to return a process that auto-triggers 'close' + * with the given exit code via queueMicrotask, after all handlers are registered. + */ + function setupGssapiSpawn(exitCode: number, stderrOutput?: string) { + mockSpawn.mockImplementation(() => { + const stderrHandlers: Record void> = {}; + const mockProc = { + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + if (event === 'close') { + // Fire close asynchronously so all handlers are registered first + queueMicrotask(() => { + if (stderrOutput && stderrHandlers['data']) { + stderrHandlers['data'](Buffer.from(stderrOutput)); + } + handler(exitCode); + }); + } + }), + stderr: { + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + stderrHandlers[event] = handler; + }), + }, + stdout: { on: vi.fn() }, + stdin: { on: vi.fn() }, + killed: false, + kill: vi.fn(), + }; + return mockProc; + }); + } + + it('should establish a GSSAPI connection via ControlMaster', async () => { + const config: SshConfig = { + id: 'conn-gssapi-1', + name: 'GSSAPI Connection', + host: 'krb.example.com', + port: 22, + username: 'krbuser', + authType: 'gssapi', + }; + + setupGssapiSpawn(0); + + const connectionId = await service.connect(config); + expect(connectionId).toBe('conn-gssapi-1'); + expect(service.isConnected('conn-gssapi-1')).toBe(true); + expect(service.isGssapiConnection('conn-gssapi-1')).toBe(true); + + // Verify spawn was called with GSSAPI flags + expect(mockSpawn).toHaveBeenCalledWith( + 'ssh', + expect.arrayContaining([ + '-f', + '-N', + '-M', + '-o', + 'GSSAPIAuthentication=yes', + '-l', + 'krbuser', + 'krb.example.com', + ]), + expect.any(Object) + ); + }); + + it('should reject when GSSAPI authentication fails', async () => { + const config: SshConfig = { + id: 'conn-gssapi-fail', + name: 'GSSAPI Fail', + host: 'krb.example.com', + port: 22, + username: 'krbuser', + authType: 'gssapi', + }; + + setupGssapiSpawn(255, 'Permission denied'); + + service.on('error', () => {}); + await expect(service.connect(config)).rejects.toThrow('Permission denied'); + }); + + it('should throw SFTP error for GSSAPI connections', async () => { + const config: SshConfig = { + id: 'conn-gssapi-sftp', + name: 'GSSAPI SFTP', + host: 'krb.example.com', + port: 22, + username: 'krbuser', + authType: 'gssapi', + }; + + setupGssapiSpawn(0); + await service.connect(config); + + await expect(service.getSftp('conn-gssapi-sftp')).rejects.toThrow( + 'SFTP is not available for GSSAPI connections' + ); + }); + + it('should execute commands via system ssh for GSSAPI connections', async () => { + const config: SshConfig = { + id: 'conn-gssapi-exec', + name: 'GSSAPI Exec', + host: 'krb.example.com', + port: 22, + username: 'krbuser', + authType: 'gssapi', + }; + + setupGssapiSpawn(0); + await service.connect(config); + + // Mock execFile for command execution + mockExecFile.mockImplementation( + (cmd: string, args: string[], opts: any, callback: (...cbArgs: any[]) => void) => { + callback(null, 'hello world\n', ''); + } + ); + + const result = await service.executeCommand('conn-gssapi-exec', 'echo hello world'); + expect(result.stdout).toBe('hello world'); + expect(result.exitCode).toBe(0); + expect(mockExecFile).toHaveBeenCalledWith( + 'ssh', + expect.arrayContaining(['-o', 'ControlMaster=no', 'krb.example.com']), + expect.any(Object), + expect.any(Function) + ); + }); + + it('should return GSSAPI connection info', async () => { + const config: SshConfig = { + id: 'conn-gssapi-info', + name: 'GSSAPI Info', + host: 'krb.example.com', + port: 22, + username: 'krbuser', + authType: 'gssapi', + }; + + setupGssapiSpawn(0); + await service.connect(config); + + const info = service.getConnectionInfo('conn-gssapi-info'); + expect(info).not.toBeNull(); + expect(info?.connectedAt).toBeInstanceOf(Date); + + const connections = service.listConnections(); + expect(connections).toContain('conn-gssapi-info'); + }); + }); + describe('escapeShellArg', () => { it('should escape single quotes in shell arguments', async () => { const config: SshConfig = { diff --git a/src/main/services/ssh/types.ts b/src/main/services/ssh/types.ts index 8dd788db1..1134a38df 100644 --- a/src/main/services/ssh/types.ts +++ b/src/main/services/ssh/types.ts @@ -22,6 +22,14 @@ export interface HostKeyEntry { verifiedAt: Date; } +export interface GssapiConnection { + id: string; + config: SshConfig; + controlSocketPath: string; + connectedAt: Date; + lastActivity: Date; +} + export interface ConnectionMetrics { connectionId: string; bytesSent: number; diff --git a/src/renderer/components/ssh/AddRemoteProjectModal.tsx b/src/renderer/components/ssh/AddRemoteProjectModal.tsx index 52bb4a437..77e266d6b 100644 --- a/src/renderer/components/ssh/AddRemoteProjectModal.tsx +++ b/src/renderer/components/ssh/AddRemoteProjectModal.tsx @@ -40,7 +40,7 @@ import { } from 'lucide-react'; type WizardStep = 'connection' | 'auth' | 'path' | 'confirm'; -type AuthType = 'password' | 'key' | 'agent'; +type AuthType = 'password' | 'key' | 'agent' | 'gssapi'; type TestStatus = 'idle' | 'testing' | 'success' | 'error'; type RepoMode = 'pick' | 'create' | 'clone'; @@ -806,7 +806,8 @@ export const AddRemoteProjectModal: React.FC = ({ { id: 'auth', label: 'Authentication', - icon: formData.authType === 'password' ? Lock : Key, + icon: + formData.authType === 'password' ? Lock : formData.authType === 'gssapi' ? Globe : Key, }, { id: 'path', label: 'Project Path', icon: FolderOpen }, { id: 'confirm', label: 'Confirm', icon: Check }, @@ -1018,7 +1019,7 @@ export const AddRemoteProjectModal: React.FC = ({ updateField('authType', value as AuthType)} - className="grid grid-cols-3 gap-3" + className="grid grid-cols-4 gap-3" >
@@ -1061,6 +1062,20 @@ export const AddRemoteProjectModal: React.FC = ({ Agent
+ +
+ + +
@@ -1177,6 +1192,36 @@ export const AddRemoteProjectModal: React.FC = ({ )} + {formData.authType === 'gssapi' && ( + + + +
+

+ GSSAPI/Kerberos authentication uses your system's Kerberos ticket. Run{' '} + kinit before + connecting. +

+
+ + How to set up Kerberos + +
+

1. Obtain a Kerberos ticket:

+

kinit username@REALM

+

2. Verify the ticket is valid:

+

klist

+

3. Ensure the server supports GSSAPI auth:

+

+ ssh -o GSSAPIAuthentication=yes user@host +

+
+
+
+
+
+ )} + {/* Connection Test Result */} {testStatus !== 'idle' && ( = ({ {formData.authType === 'password' && 'Password'} {formData.authType === 'key' && 'SSH Key'} {formData.authType === 'agent' && 'SSH Agent'} + {formData.authType === 'gssapi' && 'GSSAPI/Kerberos'}
diff --git a/src/renderer/components/ssh/SshConnectionForm.tsx b/src/renderer/components/ssh/SshConnectionForm.tsx index 9aa0acd81..9b5797dae 100644 --- a/src/renderer/components/ssh/SshConnectionForm.tsx +++ b/src/renderer/components/ssh/SshConnectionForm.tsx @@ -118,7 +118,7 @@ export const SshConnectionForm: React.FC = ({ setIsLoadingFromConfig(true); // Determine auth type based on config - let authType: 'password' | 'key' | 'agent' = 'agent'; + let authType: 'password' | 'key' | 'agent' | 'gssapi' = 'agent'; let privateKeyPath = ''; if (host.identityAgent) { @@ -326,7 +326,9 @@ export const SshConnectionForm: React.FC = ({ handleChange('authType', value)} + onValueChange={(value: 'password' | 'key' | 'agent' | 'gssapi') => + handleChange('authType', value) + } className="flex flex-col gap-3" >
@@ -370,6 +372,22 @@ export const SshConnectionForm: React.FC = ({
+ +
+ + +
diff --git a/src/renderer/components/ssh/SshConnectionList.tsx b/src/renderer/components/ssh/SshConnectionList.tsx index 665383fe3..c4869b564 100644 --- a/src/renderer/components/ssh/SshConnectionList.tsx +++ b/src/renderer/components/ssh/SshConnectionList.tsx @@ -13,7 +13,7 @@ export interface SshConnection { host: string; port: number; username: string; - authType: 'password' | 'key' | 'agent'; + authType: 'password' | 'key' | 'agent' | 'gssapi'; privateKeyPath?: string; useAgent?: boolean; state?: ConnectionState; diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 444868b42..707b6a7da 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -986,7 +986,7 @@ declare global { host: string; port: number; username: string; - authType: 'password' | 'key' | 'agent'; + authType: 'password' | 'key' | 'agent' | 'gssapi'; privateKeyPath?: string; useAgent?: boolean; password?: string; @@ -998,7 +998,7 @@ declare global { host: string; port: number; username: string; - authType: 'password' | 'key' | 'agent'; + authType: 'password' | 'key' | 'agent' | 'gssapi'; privateKeyPath?: string; useAgent?: boolean; password?: string; @@ -1009,7 +1009,7 @@ declare global { host: string; port: number; username: string; - authType: 'password' | 'key' | 'agent'; + authType: 'password' | 'key' | 'agent' | 'gssapi'; privateKeyPath?: string; useAgent?: boolean; }>; @@ -1020,7 +1020,7 @@ declare global { host: string; port: number; username: string; - authType: 'password' | 'key' | 'agent'; + authType: 'password' | 'key' | 'agent' | 'gssapi'; privateKeyPath?: string; useAgent?: boolean; }> @@ -1035,7 +1035,7 @@ declare global { host: string; port: number; username: string; - authType: 'password' | 'key' | 'agent'; + authType: 'password' | 'key' | 'agent' | 'gssapi'; privateKeyPath?: string; useAgent?: boolean; password?: string; diff --git a/src/shared/ssh/types.ts b/src/shared/ssh/types.ts index 3cf4be99d..eeeb96c58 100644 --- a/src/shared/ssh/types.ts +++ b/src/shared/ssh/types.ts @@ -8,7 +8,7 @@ export interface SshConfig { host: string; port: number; username: string; - authType: 'password' | 'key' | 'agent'; + authType: 'password' | 'key' | 'agent' | 'gssapi'; privateKeyPath?: string; useAgent?: boolean; }