diff --git a/src/crates/services/services-integrations/src/remote_ssh/manager.rs b/src/crates/services/services-integrations/src/remote_ssh/manager.rs index be7cde8f39..01110007c5 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/manager.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/manager.rs @@ -59,6 +59,88 @@ fn ssh_cfg_has(settings: &std::collections::HashMap<&str, &str>, canonical_key: .any(|k| k.eq_ignore_ascii_case(canonical_key)) } +/// Manually parse `~/.ssh/config` content into Host blocks with their direct settings. +/// +/// This is a fallback for when `SSHConfig::parse_str` fails — which happens when the +/// config contains directives (e.g. `Include`, `Match`) before the first `Host` block, +/// because `ssh_config` 0.1's `EntryParser` returns `InvalidHostEntry` for any key-value +/// pair without a preceding `Host` directive, and `parse_str` fails the entire operation +/// on the first parser error. +/// +/// Only direct settings within each `Host` block are captured; pattern-matched settings +/// from wildcard blocks (e.g. `Host *`) are not applied. Use `SSHConfig::query()` as a +/// supplementary source when `parse_str` succeeds. +#[cfg(feature = "ssh_config")] +fn parse_ssh_config_manually(content: &str) -> Vec { + let mut hosts = Vec::new(); + let mut current_host: Option = None; + let mut block_hostname: Option = None; + let mut block_port: Option = None; + let mut block_user: Option = None; + let mut block_identity_file: Option = None; + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let (keyword, value) = match line.split_once(char::is_whitespace) { + Some((k, v)) => (k, v.trim()), + None => continue, + }; + + if keyword.eq_ignore_ascii_case("Host") { + // Save previous host block + if let Some(host) = current_host.take() { + hosts.push(SSHConfigEntry { + host, + hostname: block_hostname.take(), + port: block_port.take(), + user: block_user.take(), + identity_file: block_identity_file.take(), + agent: None, + }); + } + + // Start new host block — take the first alias + current_host = value.split_whitespace().next().map(|s| s.to_string()); + block_hostname = None; + block_port = None; + block_user = None; + block_identity_file = None; + } else if current_host.is_some() { + // Track details within the current Host block + if keyword.eq_ignore_ascii_case("HostName") { + block_hostname = value.split_whitespace().next().map(|s| s.to_string()); + } else if keyword.eq_ignore_ascii_case("Port") { + block_port = value.split_whitespace().next().and_then(|s| s.parse().ok()); + } else if keyword.eq_ignore_ascii_case("User") { + block_user = value.split_whitespace().next().map(|s| s.to_string()); + } else if keyword.eq_ignore_ascii_case("IdentityFile") { + block_identity_file = value + .split_whitespace() + .next() + .map(|s| shellexpand::tilde(s).to_string()); + } + } + } + + // Save last host block + if let Some(host) = current_host { + hosts.push(SSHConfigEntry { + host, + hostname: block_hostname, + port: block_port, + user: block_user, + identity_file: block_identity_file, + agent: None, + }); + } + + hosts +} + /// Known hosts entry #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct KnownHostEntry { @@ -613,53 +695,58 @@ impl SSHConnectionManager { } }; - let config = match SSHConfig::parse_str(&config_content) { - Ok(c) => c, - Err(e) => { - log::warn!("Failed to parse SSH config: {:?}", e); + // Try to parse with the ssh_config crate for pattern-matching query support. + // If parsing fails, fall back to manual parsing. + let parsed_config = SSHConfig::parse_str(&config_content).ok(); + if parsed_config.is_none() { + log::debug!("SSHConfig::parse_str failed, using manual fallback"); + } + + // Try pattern-matched query first (handles Host wildcards like `Host *.example.com`) + if let Some(ref config) = parsed_config { + let host_settings = config.query(host); + if !host_settings.is_empty() { + log::debug!( + "Found SSH config for host: {} with {} settings", + host, + host_settings.len() + ); + + let hostname = ssh_cfg_get(&host_settings, "HostName").map(|s| s.to_string()); + let user = ssh_cfg_get(&host_settings, "User").map(|s| s.to_string()); + let port = ssh_cfg_get(&host_settings, "Port").and_then(|s| s.parse::().ok()); + let identity_file = ssh_cfg_get(&host_settings, "IdentityFile") + .map(|f| shellexpand::tilde(f).to_string()); + let has_proxy_command = ssh_cfg_has(&host_settings, "ProxyCommand"); + return SSHConfigLookupResult { - found: false, - config: None, + found: true, + config: Some(SSHConfigEntry { + host: host.to_string(), + hostname, + port, + user, + identity_file, + agent: if has_proxy_command { None } else { Some(true) }, + }), }; } - }; - - // Use query() to get host configuration - this handles Host pattern matching - let host_settings = config.query(host); + } - if host_settings.is_empty() { - log::debug!("No SSH config found for host: {}", host); + // Fallback: manual lookup for exact host name match + let hosts = parse_ssh_config_manually(&config_content); + if let Some(entry) = hosts.into_iter().find(|e| e.host == host) { + log::debug!("Found SSH config for host: {} (manual fallback)", host); return SSHConfigLookupResult { - found: false, - config: None, + found: true, + config: Some(entry), }; } - log::debug!( - "Found SSH config for host: {} with {} settings", - host, - host_settings.len() - ); - - // Canonical OpenSSH names; lookup is case-insensitive (see ssh_cfg_get). - let hostname = ssh_cfg_get(&host_settings, "HostName").map(|s| s.to_string()); - let user = ssh_cfg_get(&host_settings, "User").map(|s| s.to_string()); - let port = ssh_cfg_get(&host_settings, "Port").and_then(|s| s.parse::().ok()); - let identity_file = - ssh_cfg_get(&host_settings, "IdentityFile").map(|f| shellexpand::tilde(f).to_string()); - - let has_proxy_command = ssh_cfg_has(&host_settings, "ProxyCommand"); - + log::debug!("No SSH config found for host: {}", host); SSHConfigLookupResult { - found: true, - config: Some(SSHConfigEntry { - host: host.to_string(), - hostname, - port, - user, - identity_file, - agent: if has_proxy_command { None } else { Some(true) }, - }), + found: false, + config: None, } } @@ -691,52 +778,39 @@ impl SSHConnectionManager { } }; - let config = match SSHConfig::parse_str(&config_content) { - Ok(c) => c, - Err(e) => { - log::warn!("Failed to parse SSH config: {:?}", e); - return Vec::new(); - } - }; - - let mut hosts = Vec::new(); - - // SSHConfig library doesn't expose listing all hosts, so we parse the raw config - // to extract Host entries. This is a simple but effective approach. - for line in config_content.lines() { - let line = line.trim(); - // Match "Host alias1 alias2 ..." lines (but not "HostName") - if line.starts_with("Host ") && !line.starts_with("HostName") { - // Extract everything after "Host " - let host_part = line.strip_prefix("Host ").unwrap_or("").trim(); - if host_part.is_empty() { - continue; - } - // Host can be "alias1 alias2 ..." - we want the first one (main alias) - let aliases: Vec<&str> = host_part.split_whitespace().collect(); - if aliases.is_empty() { - continue; + // Try to parse with the ssh_config crate for pattern-matching query support. + // If parsing fails (e.g. `Include`/`Match` directives before the first `Host` + // block cause ssh_config 0.1 to return InvalidHostEntry), fall back to manual + // line-by-line parsing. + let parsed_config = SSHConfig::parse_str(&config_content).ok(); + if parsed_config.is_none() { + log::debug!("SSHConfig::parse_str failed, using manual fallback"); + } + + // Always do manual parsing to get the host list (SSHConfig doesn't expose all hosts). + let mut hosts = parse_ssh_config_manually(&config_content); + + // When the ssh_config crate parsed successfully, override with pattern-matched + // details (e.g. settings from `Host *` blocks that apply to all hosts). + if let Some(ref config) = parsed_config { + for entry in &mut hosts { + let settings = config.query(&entry.host); + if !settings.is_empty() { + if let Some(h) = ssh_cfg_get(&settings, "HostName") { + entry.hostname = Some(h.to_string()); + } + if let Some(p) = + ssh_cfg_get(&settings, "Port").and_then(|s| s.parse::().ok()) + { + entry.port = Some(p); + } + if let Some(u) = ssh_cfg_get(&settings, "User") { + entry.user = Some(u.to_string()); + } + if let Some(f) = ssh_cfg_get(&settings, "IdentityFile") { + entry.identity_file = Some(shellexpand::tilde(f).to_string()); + } } - - let alias = aliases[0]; - // Query config for this host to get details - let settings = config.query(alias); - - let identity_file = ssh_cfg_get(&settings, "IdentityFile") - .map(|f| shellexpand::tilde(f).to_string()); - - let hostname = ssh_cfg_get(&settings, "HostName").map(|s| s.to_string()); - let user = ssh_cfg_get(&settings, "User").map(|s| s.to_string()); - let port = ssh_cfg_get(&settings, "Port").and_then(|s| s.parse::().ok()); - - hosts.push(SSHConfigEntry { - host: alias.to_string(), - hostname, - port, - user, - identity_file, - agent: None, // Can't easily determine agent setting from raw parsing - }); } } diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss index ad2b8b281f..da47a85292 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss @@ -75,6 +75,10 @@ background: var(--color-success-bg); color: var(--color-success); } + + .ssh-connection-dialog__saved-actions { + opacity: 1; + } } } diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx index 78f74b74fa..df70bc8756 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx @@ -13,7 +13,7 @@ import { Input } from '@/component-library'; import { Select } from '@/component-library'; import { Alert } from '@/component-library'; import { IconButton } from '@/component-library'; -import { FolderOpen, Loader2, Server, User, Key, Lock, Trash2, Plus, Pencil, Play } from 'lucide-react'; +import { FolderOpen, Loader2, Server, User, Key, Lock, Trash2, Plus, Pencil, Play, ArrowDownToLine } from 'lucide-react'; import type { SSHConnectionConfig, SSHAuthMethod, @@ -24,15 +24,6 @@ import { sshApi } from './sshApi'; import { pickSshPrivateKeyPath } from './pickSshPrivateKeyPath'; import './SSHConnectionDialog.scss'; -type CredentialsPromptState = - | { kind: 'saved'; connection: SavedConnection } - | { - kind: 'sshConfig'; - entry: SSHConfigEntry; - connectHost: string; - port: number; - }; - interface SSHConnectionDialogProps { open: boolean; onClose: () => void; @@ -48,7 +39,7 @@ export const SSHConnectionDialog: React.FC = ({ const [sshConfigHosts, setSSHConfigHosts] = useState([]); const [localError, setLocalError] = useState(null); const [isConnecting, setIsConnecting] = useState(false); - const [credentialsPrompt, setCredentialsPrompt] = useState(null); + const [credentialsPrompt, setCredentialsPrompt] = useState(null); const error = localError || connectionError; @@ -78,7 +69,8 @@ export const SSHConnectionDialog: React.FC = ({ try { const hosts = await sshApi.listSSHConfigHosts(); setSSHConfigHosts(hosts); - } catch (_error) { + } catch (error) { + console.error('Failed to load SSH config hosts:', error); setSSHConfigHosts([]); } } @@ -233,7 +225,7 @@ export const SSHConnectionDialog: React.FC = ({ { browseAfterConnect: true } ); } catch { - setCredentialsPrompt({ kind: 'saved', connection: conn }); + setCredentialsPrompt(conn); } finally { setIsConnecting(false); } @@ -265,95 +257,42 @@ export const SSHConnectionDialog: React.FC = ({ } }; - // Handle connecting to a host from ~/.ssh/config - const handleSSHConfigConnect = async (configHost: SSHConfigEntry) => { - setLocalError(null); - - // Build hostname - if hostname is set in config, use it; otherwise use the host alias - const hostname = configHost.hostname || configHost.host; + // Fill the manual connection form from an ~/.ssh/config host entry + const handleFillFromConfig = (configHost: SSHConfigEntry) => { + const port = configHost.port ? String(configHost.port) : '22'; const username = configHost.user || ''; - const port = configHost.port || 22; - - const hasValidIdentityFile = configHost.identityFile && configHost.identityFile.trim() !== ''; - - if (!hasValidIdentityFile) { - setCredentialsPrompt({ - kind: 'sshConfig', - entry: configHost, - connectHost: hostname, - port, - }); - return; - } + const keyPath = configHost.identityFile?.trim() || '~/.ssh/id_rsa'; + const hasKey = !!configHost.identityFile?.trim(); - const connectionId = generateConnectionId(hostname, port, username); - const name = configHost.host; - - const authConfig: SSHConnectionConfig = { - id: connectionId, - name, - host: hostname, + setFormData({ + name: configHost.host, + host: configHost.host, port, username, - auth: { type: 'PrivateKey', keyPath: configHost.identityFile! }, - }; - - setIsConnecting(true); - try { - await connect(connectionId, authConfig, { browseAfterConnect: true }); - } catch (e) { - setLocalError(e instanceof Error ? e.message : 'Authentication failed'); - } finally { - setIsConnecting(false); - } - }; - - const credentialsTargetDescription = (state: CredentialsPromptState): string => { - if (state.kind === 'saved') { - const c = state.connection; - return `${c.username}@${c.host}:${c.port}`; - } - const { entry, connectHost, port } = state; - const u = entry.user?.trim(); - const base = u ? `${u}@${connectHost}:${port}` : `${connectHost}:${port}`; - if (entry.host && entry.host !== connectHost) { - return `${base} (${entry.host})`; - } - return base; + authType: hasKey ? 'privateKey' : 'password', + password: '', + keyPath, + passphrase: '', + }); }; const handleCredentialsPromptSubmit = async (payload: SSHAuthPromptSubmitPayload) => { if (!credentialsPrompt) return; const { auth, username: resolvedUsername } = payload; + const conn = credentialsPrompt; setIsConnecting(true); setLocalError(null); try { - if (credentialsPrompt.kind === 'saved') { - const conn = credentialsPrompt.connection; - const full: SSHConnectionConfig = { - id: conn.id, - name: conn.name, - host: conn.host, - port: conn.port, - username: resolvedUsername, - auth, - }; - await connect(conn.id, full, { browseAfterConnect: true }); - } else { - const { entry, connectHost, port } = credentialsPrompt; - const connectionId = generateConnectionId(connectHost, port, resolvedUsername); - const full: SSHConnectionConfig = { - id: connectionId, - name: entry.host, - host: connectHost, - port, - username: resolvedUsername, - auth, - }; - await connect(connectionId, full, { browseAfterConnect: true }); - await loadSavedConnections(); - } + const full: SSHConnectionConfig = { + id: conn.id, + name: conn.name, + host: conn.host, + port: conn.port, + username: resolvedUsername, + auth, + }; + await connect(conn.id, full, { browseAfterConnect: true }); setCredentialsPrompt(null); } catch (e) { setLocalError(e instanceof Error ? e.message : 'Connection failed'); @@ -514,10 +453,10 @@ export const SSHConnectionDialog: React.FC = ({
!isConnecting && handleSSHConfigConnect(configHost)} + onClick={() => !isConnecting && handleFillFromConfig(configHost)} role="button" tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && !isConnecting && handleSSHConfigConnect(configHost)} + onKeyDown={(e) => e.key === 'Enter' && !isConnecting && handleFillFromConfig(configHost)} >
@@ -531,15 +470,16 @@ export const SSHConnectionDialog: React.FC = ({
@@ -708,23 +648,11 @@ export const SSHConnectionDialog: React.FC = ({ {credentialsPrompt && (