Skip to content

Add GSSAPI/Kerberos SSH authentication support#1382

Open
liujunandzhou wants to merge 1 commit intogeneralaction:mainfrom
liujunandzhou:claude/explain-codebase-mmjz4319tvctf1ae-w0y2d
Open

Add GSSAPI/Kerberos SSH authentication support#1382
liujunandzhou wants to merge 1 commit intogeneralaction:mainfrom
liujunandzhou:claude/explain-codebase-mmjz4319tvctf1ae-w0y2d

Conversation

@liujunandzhou
Copy link

Summary

This PR adds support for GSSAPI/Kerberos SSH authentication to the application. Since the ssh2 library doesn't natively support GSSAPI, the implementation uses the system's OpenSSH client with ControlMaster multiplexing to establish and reuse authenticated connections.

Key Changes

Core SSH Service (SshService.ts)

  • Added GssapiConnection type to track GSSAPI connections separately from ssh2 connections
  • Implemented connectGssapi() method that spawns system ssh with GSSAPI flags and ControlMaster mode
  • Added executeCommandGssapi() to run commands via the ControlMaster socket using execFile
  • Implemented disconnectGssapi() for proper cleanup of ControlMaster processes and socket files
  • Updated connection pool management to account for GSSAPI connections
  • Added helper methods: isGssapiConnection(), getGssapiConnection(), getGssapiSshArgs()
  • Updated all connection-aware methods (isConnected(), listConnections(), getConnectionInfo(), disconnectAll()) to handle both ssh2 and GSSAPI connections
  • SFTP operations throw an error for GSSAPI connections (not supported via ControlMaster)

IPC Layer (sshIpc.ts)

  • Added GSSAPI support to connection testing via system ssh with verbose output for debugging
  • Implemented command-based file operations for GSSAPI connections:
    • listFiles() uses ls -la with timestamp parsing
    • readFile() uses cat
    • writeFile() uses base64 encoding for safe transmission
  • Updated auth type mapping to include 'gssapi'

Remote PTY Service (RemotePtyService.ts)

  • Extracted common command building logic (env vars, cwd, shell validation) to be shared between ssh2 and GSSAPI paths
  • Implemented startGssapiPty() that spawns ssh -tt with ControlMaster socket for interactive terminal sessions
  • GSSAPI PTY sessions use node-pty for proper terminal emulation

UI Components

  • Updated AddRemoteProjectModal.tsx to include GSSAPI/Kerberos as an authentication option with Globe icon
  • Added informational alert explaining Kerberos setup (requires kinit before connecting)
  • Updated SshConnectionForm.tsx to support GSSAPI auth type selection
  • Updated type definitions across renderer components to include 'gssapi' auth type

Type Definitions

  • Added GssapiConnection interface in src/main/services/ssh/types.ts
  • Updated SshConfig and related types to include 'gssapi' as valid auth type

Testing

Added comprehensive unit tests in SshService.test.ts:

  • GSSAPI connection establishment with ControlMaster
  • Authentication failure handling
  • SFTP error for GSSAPI connections
  • Command execution via system ssh
  • Connection info retrieval and listing

Tests mock spawn and execFile to simulate system ssh behavior without requiring actual Kerberos setup.

Type of change

  • New feature (non-breaking change which adds functionality)

Notes

  • GSSAPI connections require a valid Kerberos ticket (kinit must be run beforehand)
  • File operations for GSSAPI use command-based approaches instead of SFTP
  • ControlMaster sockets are stored in system temp directory with connection ID-based naming
  • Proper cleanup of stale socket files and processes is implemented
  • The implementation maintains backward compatibility with existing ssh2-based connections

https://claude.ai/code/session_017956U4sRFiAYbRmqujqTEV

Add a fourth SSH authentication method (GSSAPI/Kerberos) alongside
password, SSH key, and SSH agent. Since the ssh2 library doesn't support
GSSAPI natively, connections use the system's OpenSSH client with
ControlMaster sockets for persistent, multiplexed sessions.

Changes:
- Add 'gssapi' to SshConfig.authType union across shared types,
  electron-api.d.ts, and all UI components
- Add Kerberos card + setup info panel to AddRemoteProjectModal wizard
  and SshConnectionForm
- Implement ControlMaster-based GSSAPI connections in SshService
  (connect, executeCommand, disconnect via system ssh)
- Add GSSAPI test handler in sshIpc using system ssh with verbose output
- Add command-based file operations (ls/cat/base64) for GSSAPI
  connections where SFTP is unavailable
- Update RemotePtyService to spawn ssh -tt via node-pty for GSSAPI
  shell sessions instead of ssh2 client.shell()
- Add 5 unit tests covering GSSAPI connect, auth failure, SFTP error,
  command execution, and connection info

https://claude.ai/code/session_017956U4sRFiAYbRmqujqTEV
@vercel
Copy link

vercel bot commented Mar 10, 2026

@claude is attempting to deploy a commit to the General Action Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This PR adds GSSAPI/Kerberos SSH authentication by delegating to the system's OpenSSH client (via spawn/execFile) rather than ssh2, which lacks native GSSAPI support. A ControlMaster multiplexing socket is established on connect and reused for subsequent commands, PTY sessions, and file operations. The approach is architecturally sound and the UI changes, type definitions, and test coverage are clean.

Key issues found:

  • Symlink name parsing bug (sshIpc.ts): The ls -la output parser captures the full name -> /target string as the filename for symlinks. Every symlink entry will have a malformed name and path.
  • GNU-only --time-style flag (sshIpc.ts): --time-style=+%s is not supported by BSD/macOS ls. Remote hosts running macOS or BSD will silently fail all GSSAPI directory listings.
  • Stale process reference in gssapiProcesses (SshService.ts): ssh -f daemonizes on success, so the ChildProcess stored in gssapiProcesses has already exited. The proc.kill() call in disconnectGssapi targets the dead foreground process, not the background ControlMaster (which is correctly shut down via -O exit).
  • No connection establishment timeout (SshService.ts): connectGssapi() has no Node.js-level timeout. If the Kerberos exchange stalls (e.g., unreachable KDC), the Promise never settles.
  • Binary file corruption via cat (sshIpc.ts): readFile for GSSAPI connections reads via cat, with stdout decoded as a UTF-8 string by execFile. Binary files will be silently corrupted.

Confidence Score: 2/5

  • Not safe to merge — the symlink parsing bug and --time-style portability issue will cause incorrect behavior for a significant subset of users without any error indication.
  • Two concrete logic bugs (symlink names and GNU ls flag) affect file listing correctness for all GSSAPI users on macOS/BSD remote hosts or directories containing symlinks. Additionally, the missing connection timeout creates a risk of the UI hanging indefinitely on Kerberos network issues.
  • src/main/ipc/sshIpc.ts (symlink parsing, --time-style, binary reads) and src/main/services/ssh/SshService.ts (stale process tracking, missing timeout).

Important Files Changed

Filename Overview
src/main/services/ssh/SshService.ts Core GSSAPI implementation using system ssh with ControlMaster; has issues with stale process tracking after ssh -f daemonizes and no connection establishment timeout.
src/main/ipc/sshIpc.ts GSSAPI file operations via shell commands; symlink names are incorrectly parsed (including -> target), --time-style=+%s breaks on macOS/BSD remote servers, and binary file reads via cat will corrupt data.
src/main/services/RemotePtyService.ts GSSAPI PTY support via node-pty + ControlMaster looks correct; shell allowlist and env var validation are properly reused; initialPrompt send via setTimeout is a minor fragility.
src/main/services/ssh/types.ts Adds GssapiConnection interface — clean, well-typed definition with no issues.
src/main/services/ssh/tests/SshService.test.ts Good test coverage for GSSAPI paths including success, auth failure, SFTP rejection, command execution, and connection info; mock setup is sound.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User selects GSSAPI auth] --> B[SshService.connect]
    B --> C{authType?}
    C -- gssapi --> D[connectGssapi: spawn system ssh with ControlMaster flags]
    C -- other --> E[createConnection via ssh2 library]
    D --> F{ssh exits with code 0?}
    F -- yes --> G[Store GssapiConnection + socket path]
    F -- no --> H[Reject with stderr error message]
    G --> I[Connection ready]
    I --> J{Operation type?}
    J -- executeCommand --> K[execFile ssh via ControlMaster socket]
    J -- startPty --> L[node-pty spawn ssh -tt via ControlMaster socket]
    J -- getSftp --> M[Throw: SFTP not supported for GSSAPI]
    J -- listFiles --> N[execFile ls -la via executeCommand]
    J -- readFile --> O[execFile cat via executeCommand]
    J -- writeFile --> P[base64-encode content, pipe to file via executeCommand]
    J -- disconnect --> Q[execFile ssh -O exit to close ControlMaster]
    Q --> R[unlink socket file]
    R --> S[Emit disconnected]
Loading

Comments Outside Diff (1)

  1. src/main/ipc/sshIpc.ts, line 150-160 (link)

    Binary file content will be corrupted via cat

    execFile by default decodes stdout as a UTF-8 string. Reading binary files through cat will corrupt any bytes that are not valid UTF-8 (replacement characters, truncated sequences, etc.). The SFTP path used by other auth types handles binary data correctly as a Buffer.

    This is an inherent limitation of the command-based approach, but callers and UI should be aware that GSSAPI readFile is safe only for text files. A safer alternative for binary files would be to pipe through base64 on the remote side (e.g., cat file | base64) and decode on the Node side, similar to how writeFile already uses base64 encoding.

Last reviewed commit: ee18696

Comment on lines +643 to +648
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Symlink names include -> target path

When ls -la lists a symlink, the output format is lrwxrwxrwx ... name -> /path/to/target. The regex captures (.+) for the name field, which means symlink entries will have names like "somefile -> /etc/passwd" instead of just "somefile". This produces incorrect name and path values for every symlink in the directory.

A fix would be to strip the arrow and target from symlink names:

Suggested change
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;
const [, typeChar, perms, sizeStr, mtimeStr, name] = match;
const displayName = typeChar === 'l' ? name.replace(/ -> .+$/, '') : name;
if (displayName === '.' || displayName === '..') continue;

And use displayName in the entries.push() call instead of name:

            entries.push({
              path: `${path}/${displayName}`.replace(/\/+/g, '/'),
              name: displayName,
              ...

Comment on lines +632 to +634
const result = await sshService.executeCommand(
connectionId,
`ls -la --time-style=+%s ${quoteShellArg(path)} 2>/dev/null`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--time-style is a GNU ls extension that breaks on macOS/BSD remote servers

--time-style=+%s is specific to GNU coreutils ls and is not available on macOS or BSD systems. If the remote host runs macOS, this command will exit non-zero with ls: illegal option -- - (the long option form is not supported), causing all GSSAPI directory listings to fail with a generic "Failed to list files" error.

Consider a more portable approach using stat for timestamps, or fall back gracefully. For example, using ls -la without --time-style and then separately fetching timestamps, or checking uname at connection time. At minimum, the error message could hint at the root cause when this fails.

Comment on lines +423 to 464
try {
await new Promise<void>((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<void> {
// Handle GSSAPI connections
if (this.gssapiConnections.has(connectionId)) {
return this.disconnectGssapi(connectionId);
}

const connection = this.connections[connectionId];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gssapiProcesses stores a reference to an already-exited process

ssh -f -N -M is designed to daemonize — after authentication succeeds, the foreground process forks into the background and exits with code 0. The proc reference captured from spawn() is the foreground parent, which has already exited by the time close fires with code 0. Storing it in gssapiProcesses and later calling proc.kill() in disconnectGssapi() means you're killing an already-dead process (which is harmless), but in a tight system, the PID could theoretically be reused by an unrelated process.

The real ControlMaster background process is correctly shut down via ssh -S ... -O exit .... The gssapiProcesses map and the proc.kill() call in disconnectGssapi do not actually target the ControlMaster — they are redundant at best and misleading at worst. Consider removing the gssapiProcesses tracking entirely and relying solely on the -O exit signal for cleanup.

Comment on lines +388 to 465
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<void> {
const conn = this.gssapiConnections.get(connectionId);
if (!conn) return;

// Send exit command to ControlMaster
try {
await new Promise<void>((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<void> {
// Handle GSSAPI connections
if (this.gssapiConnections.has(connectionId)) {
return this.disconnectGssapi(connectionId);
}

const connection = this.connections[connectionId];
if (!connection) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No timeout on GSSAPI connection establishment

connectGssapi() wraps the spawn call in a Promise but sets no Node.js-level timeout. If ssh -f stalls — for example, because the KDC is unreachable and the Kerberos library is waiting for a network timeout — the promise will never settle and the caller will hang indefinitely. The SSH ConnectTimeout=15 option only limits the TCP handshake, not the full Kerberos exchange.

Consider adding an explicit timeout (e.g., 30–60 s) that rejects the promise and kills the spawned process:

const timer = setTimeout(() => {
  proc.kill();
  reject(new Error('GSSAPI connection timed out'));
}, 30000);

proc.on('close', (code) => {
  clearTimeout(timer);
  // ... existing logic
});

com314159 pushed a commit to com314159/emdash that referenced this pull request Mar 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants