diff --git a/example-config.yaml b/example-config.yaml index 5bf0a759..33d8e375 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -84,4 +84,39 @@ clusters: secure: nodes: - host: target.secure.internal - jump_host: ${FIRST_HOP},${SECOND_HOP} # Comma-separated for multi-hop \ No newline at end of file + jump_host: ${FIRST_HOP},${SECOND_HOP} # Comma-separated for multi-hop + + # Example: Using SSH config Host alias as jump host + # This references a Host defined in ~/.ssh/config, inheriting its settings: + # - HostName, User, Port, and IdentityFile are all read from SSH config + # + # ~/.ssh/config example: + # Host my-bastion + # HostName bastion.example.com + # User jumpuser + # Port 2222 + # IdentityFile ~/.ssh/bastion_key + # + ssh_config_ref: + nodes: + - host: target.internal + # Simple format with @ prefix references SSH config Host alias + jump_host: "@my-bastion" + + # Alternative structured format for SSH config reference + ssh_config_ref_structured: + nodes: + - host: target2.internal + jump_host: + ssh_config_host: my-bastion # References SSH config Host alias + + # Example: Per-node SSH config references + mixed_ssh_config: + nodes: + - host: node1.internal + jump_host: "@bastion-zone-a" # Different SSH config entry per node + - host: node2.internal + jump_host: "@bastion-zone-b" + - host: node3.internal + # Direct connection, no jump host + jump_host: "" \ No newline at end of file diff --git a/src/config/resolver.rs b/src/config/resolver.rs index 01723529..55833aca 100644 --- a/src/config/resolver.rs +++ b/src/config/resolver.rs @@ -17,8 +17,9 @@ use anyhow::Result; use crate::node::Node; +use crate::ssh::ssh_config::SshConfig; -use super::types::{Cluster, Config, NodeConfig}; +use super::types::{Cluster, Config, JumpHostConfig, NodeConfig}; use super::utils::{expand_env_vars, get_current_username}; impl Config { @@ -132,6 +133,9 @@ impl Config { /// 3. Global default `jump_host` (in `Defaults`) /// /// Empty string (`""`) explicitly disables jump host inheritance. + /// + /// Note: This method does not resolve SSH config references (`@alias`). + /// Use `get_jump_host_with_key_and_ssh_config` for full resolution. pub fn get_jump_host(&self, cluster_name: &str, node_index: usize) -> Option { self.get_jump_host_with_key(cluster_name, node_index) .map(|(conn_str, _)| conn_str) @@ -146,10 +150,34 @@ impl Config { /// /// Empty string (`""`) explicitly disables jump host inheritance. /// Returns tuple of (connection_string, optional_ssh_key_path) + /// + /// Note: This method does not resolve SSH config references (`@alias`). + /// Use `get_jump_host_with_key_and_ssh_config` for full resolution. pub fn get_jump_host_with_key( &self, cluster_name: &str, node_index: usize, + ) -> Option<(String, Option)> { + self.get_jump_host_with_key_and_ssh_config(cluster_name, node_index, None) + } + + /// Get jump host with SSH key for a specific node, with SSH config reference resolution. + /// + /// This is the full-featured version that can resolve SSH config Host alias references + /// (`@alias` or `ssh_config_host` field) using the provided SSH config. + /// + /// Resolution priority (highest to lowest): + /// 1. Node-level `jump_host` (in `NodeConfig::Detailed`) + /// 2. Cluster-level `jump_host` (in `ClusterDefaults`) + /// 3. Global default `jump_host` (in `Defaults`) + /// + /// Empty string (`""`) explicitly disables jump host inheritance. + /// Returns tuple of (connection_string, optional_ssh_key_path) + pub fn get_jump_host_with_key_and_ssh_config( + &self, + cluster_name: &str, + node_index: usize, + ssh_config: Option<&SshConfig>, ) -> Option<(String, Option)> { if let Some(cluster) = self.get_cluster(cluster_name) { // Check node-level first @@ -158,31 +186,36 @@ impl Config { .. }) = cluster.nodes.get(node_index) { - return self.process_jump_host_config(jh); + return self.process_jump_host_config(jh, ssh_config); } // Check cluster-level if let Some(jh) = &cluster.defaults.jump_host { - return self.process_jump_host_config(jh); + return self.process_jump_host_config(jh, ssh_config); } } // Fall back to global default self.defaults .jump_host .as_ref() - .and_then(|jh| self.process_jump_host_config(jh)) + .and_then(|jh| self.process_jump_host_config(jh, ssh_config)) } /// Process a JumpHostConfig and return (connection_string, optional_ssh_key_path) + /// + /// If `ssh_config` is provided, SSH config references (`@alias` or `ssh_config_host`) + /// will be resolved using the SSH config. Otherwise, the reference string is returned as-is. fn process_jump_host_config( &self, - config: &super::types::JumpHostConfig, + config: &JumpHostConfig, + ssh_config: Option<&SshConfig>, ) -> Option<(String, Option)> { - use super::types::JumpHostConfig; - match config { JumpHostConfig::Simple(s) => { if s.is_empty() { None // Explicitly disabled + } else if let Some(alias) = s.strip_prefix('@') { + // SSH config reference with @ prefix + self.resolve_ssh_config_jump_host(alias, ssh_config) } else { Some((expand_env_vars(s), None)) } @@ -206,9 +239,42 @@ impl Config { let key = ssh_key.as_ref().map(|k| expand_env_vars(k)); Some((conn_str, key)) } + JumpHostConfig::SshConfigHostRef { ssh_config_host } => { + self.resolve_ssh_config_jump_host(ssh_config_host, ssh_config) + } } } + /// Resolve an SSH config Host alias to connection string and SSH key. + /// + /// If `ssh_config` is provided, looks up the alias and extracts: + /// - HostName (or uses the alias as hostname) + /// - User + /// - Port + /// - IdentityFile (first one, used as SSH key) + /// + /// If `ssh_config` is None, returns the alias as the hostname with no SSH key. + fn resolve_ssh_config_jump_host( + &self, + alias: &str, + ssh_config: Option<&SshConfig>, + ) -> Option<(String, Option)> { + if let Some(ssh_cfg) = ssh_config { + // Try to resolve from SSH config + if let Some((conn_str, identity_file)) = ssh_cfg.resolve_jump_host_connection(alias) { + return Some((conn_str, identity_file)); + } + } + + // Fallback: use the alias as the hostname (SSH will resolve it) + // This allows the connection to proceed even without explicit SSH config resolution + tracing::debug!( + "SSH config reference '{}' could not be resolved, using as hostname", + alias + ); + Some((alias.to_string(), None)) + } + /// Get jump host for a cluster (cluster-level default). /// /// Resolution priority (highest to lowest): @@ -232,11 +298,27 @@ impl Config { pub fn get_cluster_jump_host_with_key( &self, cluster_name: Option<&str>, + ) -> Option<(String, Option)> { + self.get_cluster_jump_host_with_key_and_ssh_config(cluster_name, None) + } + + /// Get jump host with SSH key for a cluster, with SSH config reference resolution. + /// + /// Resolution priority (highest to lowest): + /// 1. Cluster-level `jump_host` (in `ClusterDefaults`) + /// 2. Global default `jump_host` (in `Defaults`) + /// + /// Empty string (`""`) explicitly disables jump host inheritance. + /// Returns tuple of (connection_string, optional_ssh_key_path) + pub fn get_cluster_jump_host_with_key_and_ssh_config( + &self, + cluster_name: Option<&str>, + ssh_config: Option<&SshConfig>, ) -> Option<(String, Option)> { if let Some(cluster_name) = cluster_name { if let Some(cluster) = self.get_cluster(cluster_name) { if let Some(jh) = &cluster.defaults.jump_host { - return self.process_jump_host_config(jh); + return self.process_jump_host_config(jh, ssh_config); } } } @@ -244,7 +326,7 @@ impl Config { self.defaults .jump_host .as_ref() - .and_then(|jh| self.process_jump_host_config(jh)) + .and_then(|jh| self.process_jump_host_config(jh, ssh_config)) } /// Get SSH keepalive interval for a cluster. diff --git a/src/config/types.rs b/src/config/types.rs index 752293ba..7fd092fb 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -32,13 +32,23 @@ pub struct Config { /// Jump host configuration format. /// -/// Supports both legacy string format and structured format with optional SSH key. -/// Uses `#[serde(untagged)]` to allow seamless deserialization of both formats. +/// Supports multiple formats: +/// - Legacy string format: `"[user@]hostname[:port]"` +/// - SSH config reference: `"@alias"` (references ~/.ssh/config Host alias) +/// - Structured format with optional ssh_key +/// - Structured SSH config reference with ssh_config_host field +/// +/// Uses `#[serde(untagged)]` to allow seamless deserialization of all formats. #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(untagged)] pub enum JumpHostConfig { - /// Structured format with optional ssh_key field + /// Structured SSH config reference format with ssh_config_host field /// Must be listed first for serde to try matching object format before string + SshConfigHostRef { + /// SSH config Host alias to reference (from ~/.ssh/config) + ssh_config_host: String, + }, + /// Structured format with optional ssh_key field Detailed { host: String, #[serde(default)] @@ -49,6 +59,7 @@ pub enum JumpHostConfig { ssh_key: Option, }, /// Legacy string format: "[user@]hostname[:port]" + /// Also supports SSH config reference with "@" prefix: "@alias" Simple(String), } @@ -215,7 +226,11 @@ pub(super) fn default_quit() -> String { } impl JumpHostConfig { - /// Convert to a connection string for resolution + /// Convert to a connection string for resolution. + /// + /// Note: For SSH config references (`@alias` or `ssh_config_host`), this returns + /// the alias name with "@" prefix. The actual resolution to hostname/user/port + /// must be done by the caller using SSH config parsing. pub fn to_connection_string(&self) -> String { match self { JumpHostConfig::Simple(s) => s.clone(), @@ -237,6 +252,9 @@ impl JumpHostConfig { } result } + JumpHostConfig::SshConfigHostRef { ssh_config_host } => { + format!("@{}", ssh_config_host) + } } } @@ -245,6 +263,30 @@ impl JumpHostConfig { match self { JumpHostConfig::Simple(_) => None, JumpHostConfig::Detailed { ssh_key, .. } => ssh_key.as_deref(), + JumpHostConfig::SshConfigHostRef { .. } => None, + } + } + + /// Check if this is an SSH config reference (either `@alias` string or `ssh_config_host` field) + pub fn is_ssh_config_ref(&self) -> bool { + match self { + JumpHostConfig::Simple(s) => s.starts_with('@'), + JumpHostConfig::SshConfigHostRef { .. } => true, + JumpHostConfig::Detailed { .. } => false, + } + } + + /// Get the SSH config host alias if this is an SSH config reference. + /// + /// Returns the alias name (without "@" prefix) for: + /// - `JumpHostConfig::Simple("@alias")` -> Some("alias") + /// - `JumpHostConfig::SshConfigHostRef { ssh_config_host: "alias" }` -> Some("alias") + /// - Other variants -> None + pub fn ssh_config_host(&self) -> Option<&str> { + match self { + JumpHostConfig::Simple(s) if s.starts_with('@') => Some(&s[1..]), + JumpHostConfig::SshConfigHostRef { ssh_config_host } => Some(ssh_config_host), + _ => None, } } } diff --git a/src/ssh/ssh_config/mod.rs b/src/ssh/ssh_config/mod.rs index 2d631f28..cbb1ebf8 100644 --- a/src/ssh/ssh_config/mod.rs +++ b/src/ssh/ssh_config/mod.rs @@ -165,6 +165,104 @@ impl SshConfig { pub fn get_all_configs(&self) -> &[SshHostConfig] { &self.hosts } + + /// Resolve jump host parameters from an SSH config Host alias. + /// + /// This method looks up a Host alias in the SSH config and extracts + /// the connection parameters needed for a jump host connection. + /// + /// # Arguments + /// * `host_alias` - The Host alias to look up (e.g., "bastion" from `Host bastion`) + /// + /// # Returns + /// A tuple of (hostname, user, port, identity_file) if the alias is found, + /// or None if the alias doesn't exist or has no configuration. + /// + /// # Example + /// For SSH config: + /// ```text + /// Host bastion + /// HostName bastion.example.com + /// User jumpuser + /// Port 2222 + /// IdentityFile ~/.ssh/bastion_key + /// ``` + /// + /// `resolve_jump_host("bastion")` returns: + /// `Some(("bastion.example.com", Some("jumpuser"), Some(2222), Some("~/.ssh/bastion_key")))` + #[allow(clippy::type_complexity)] + pub fn resolve_jump_host( + &self, + host_alias: &str, + ) -> Option<(String, Option, Option, Option)> { + let config = self.find_host_config(host_alias); + + // Get the effective hostname (HostName directive or the alias itself) + let hostname = config + .hostname + .clone() + .unwrap_or_else(|| host_alias.to_string()); + + // If no configuration was found (empty config), return None + // This happens when the alias doesn't match any Host pattern + if config.hostname.is_none() + && config.user.is_none() + && config.port.is_none() + && config.identity_files.is_empty() + { + // Check if there's at least a matching host pattern + // If not, this alias doesn't exist in SSH config + let has_matching_pattern = self.hosts.iter().any(|h| { + h.host_patterns + .iter() + .any(|p| p == host_alias || p == "*") + }); + + if !has_matching_pattern { + return None; + } + } + + // Get the first identity file if available + let identity_file = config + .identity_files + .first() + .map(|p| p.to_string_lossy().to_string()); + + Some((hostname, config.user, config.port, identity_file)) + } + + /// Resolve jump host to a connection string with SSH key. + /// + /// This is a convenience method that resolves an SSH config Host alias + /// and returns the information in a format suitable for jump host connection. + /// + /// # Arguments + /// * `host_alias` - The Host alias to look up + /// + /// # Returns + /// A tuple of (connection_string, optional_ssh_key_path) where: + /// - `connection_string` is in format `[user@]hostname[:port]` + /// - `optional_ssh_key_path` is the first IdentityFile if specified + pub fn resolve_jump_host_connection( + &self, + host_alias: &str, + ) -> Option<(String, Option)> { + let (hostname, user, port, identity_file) = self.resolve_jump_host(host_alias)?; + + let mut conn_str = String::new(); + if let Some(u) = user { + conn_str.push_str(&u); + conn_str.push('@'); + } + conn_str.push_str(&hostname); + if let Some(p) = port { + conn_str.push(':'); + conn_str.push_str(&p.to_string()); + } + + Some((conn_str, identity_file)) + } } #[cfg(test)] diff --git a/tests/jump_host_config_test.rs b/tests/jump_host_config_test.rs index a67aa959..f4f0f973 100644 --- a/tests/jump_host_config_test.rs +++ b/tests/jump_host_config_test.rs @@ -12,15 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Integration tests for jump_host configuration feature (issue #115 and #167) +//! Integration tests for jump_host configuration feature (issue #115, #167, and #170) //! //! These tests verify that jump_host can be configured in config.yaml //! at global, cluster, and node levels, and that CLI -J option takes //! precedence over configuration. //! //! Tests for issue #167 verify per-jump-host SSH key configuration. +//! Tests for issue #170 verify SSH config Host alias reference support. use bssh::config::{Config, JumpHostConfig}; +use bssh::ssh::ssh_config::SshConfig; /// Test that global default jump_host is applied to all clusters #[test] @@ -638,3 +640,468 @@ fn test_jump_host_config_ssh_key_accessor() { }; assert_eq!(config3.ssh_key(), Some("~/.ssh/key")); } + +// ===== Tests for issue #170: SSH config Host alias reference support ===== + +/// Test JumpHostConfig SshConfigHostRef parsing +#[test] +fn test_jump_host_config_ssh_config_host_ref_parsing() { + let yaml = r#" +clusters: + test: + nodes: + - host: node1 + jump_host: + ssh_config_host: bastion +"#; + + let config: Config = serde_yaml::from_str(yaml).expect("Failed to parse config"); + let cluster = config.get_cluster("test").expect("Cluster not found"); + + match &cluster.defaults.jump_host { + Some(JumpHostConfig::SshConfigHostRef { ssh_config_host }) => { + assert_eq!(ssh_config_host, "bastion"); + } + other => panic!("Expected SshConfigHostRef variant, got {:?}", other), + } +} + +/// Test JumpHostConfig Simple format with @ prefix (SSH config reference) +#[test] +fn test_jump_host_config_at_prefix_parsing() { + let yaml = r#" +clusters: + test: + nodes: + - host: node1 + jump_host: "@bastion" +"#; + + let config: Config = serde_yaml::from_str(yaml).expect("Failed to parse config"); + let cluster = config.get_cluster("test").expect("Cluster not found"); + + match &cluster.defaults.jump_host { + Some(JumpHostConfig::Simple(s)) => { + assert_eq!(s, "@bastion"); + assert!(cluster + .defaults + .jump_host + .as_ref() + .unwrap() + .is_ssh_config_ref()); + assert_eq!( + cluster.defaults.jump_host.as_ref().unwrap().ssh_config_host(), + Some("bastion") + ); + } + other => panic!("Expected Simple variant with @ prefix, got {:?}", other), + } +} + +/// Test JumpHostConfig is_ssh_config_ref method +#[test] +fn test_jump_host_config_is_ssh_config_ref() { + // Simple format without @ prefix is not SSH config ref + let config1 = JumpHostConfig::Simple("bastion.example.com".to_string()); + assert!(!config1.is_ssh_config_ref()); + + // Simple format with @ prefix is SSH config ref + let config2 = JumpHostConfig::Simple("@bastion".to_string()); + assert!(config2.is_ssh_config_ref()); + + // SshConfigHostRef is always SSH config ref + let config3 = JumpHostConfig::SshConfigHostRef { + ssh_config_host: "bastion".to_string(), + }; + assert!(config3.is_ssh_config_ref()); + + // Detailed format is not SSH config ref + let config4 = JumpHostConfig::Detailed { + host: "bastion.example.com".to_string(), + user: None, + port: None, + ssh_key: None, + }; + assert!(!config4.is_ssh_config_ref()); +} + +/// Test JumpHostConfig ssh_config_host method +#[test] +fn test_jump_host_config_ssh_config_host() { + // Simple format without @ prefix has no ssh_config_host + let config1 = JumpHostConfig::Simple("bastion.example.com".to_string()); + assert!(config1.ssh_config_host().is_none()); + + // Simple format with @ prefix returns alias (without @) + let config2 = JumpHostConfig::Simple("@bastion".to_string()); + assert_eq!(config2.ssh_config_host(), Some("bastion")); + + // SshConfigHostRef returns the alias + let config3 = JumpHostConfig::SshConfigHostRef { + ssh_config_host: "mybastion".to_string(), + }; + assert_eq!(config3.ssh_config_host(), Some("mybastion")); + + // Detailed format has no ssh_config_host + let config4 = JumpHostConfig::Detailed { + host: "bastion.example.com".to_string(), + user: None, + port: None, + ssh_key: None, + }; + assert!(config4.ssh_config_host().is_none()); +} + +/// Test JumpHostConfig to_connection_string for SSH config references +#[test] +fn test_jump_host_config_ssh_config_ref_to_connection_string() { + // Simple format with @ prefix returns full @alias string + let config1 = JumpHostConfig::Simple("@bastion".to_string()); + assert_eq!(config1.to_connection_string(), "@bastion"); + + // SshConfigHostRef returns @alias format + let config2 = JumpHostConfig::SshConfigHostRef { + ssh_config_host: "mybastion".to_string(), + }; + assert_eq!(config2.to_connection_string(), "@mybastion"); +} + +/// Test SSH config jump host resolution +#[test] +fn test_ssh_config_resolve_jump_host() { + let ssh_config_content = r#" +Host bastion + HostName bastion.example.com + User jumpuser + Port 2222 + IdentityFile ~/.ssh/bastion_key + +Host gateway + HostName gateway.example.com + User admin + +Host simple + HostName simple.example.com +"#; + + let ssh_config = SshConfig::parse(ssh_config_content).expect("Failed to parse SSH config"); + + // Test full configuration + let result = ssh_config.resolve_jump_host("bastion"); + assert!(result.is_some()); + let (hostname, user, port, identity_file) = result.unwrap(); + assert_eq!(hostname, "bastion.example.com"); + assert_eq!(user.as_deref(), Some("jumpuser")); + assert_eq!(port, Some(2222)); + assert!(identity_file.is_some()); + assert!(identity_file.as_ref().unwrap().contains("bastion_key")); + + // Test partial configuration + let result2 = ssh_config.resolve_jump_host("gateway"); + assert!(result2.is_some()); + let (hostname2, user2, port2, identity_file2) = result2.unwrap(); + assert_eq!(hostname2, "gateway.example.com"); + assert_eq!(user2.as_deref(), Some("admin")); + assert!(port2.is_none()); + assert!(identity_file2.is_none()); + + // Test minimal configuration + let result3 = ssh_config.resolve_jump_host("simple"); + assert!(result3.is_some()); + let (hostname3, user3, port3, identity_file3) = result3.unwrap(); + assert_eq!(hostname3, "simple.example.com"); + assert!(user3.is_none()); + assert!(port3.is_none()); + assert!(identity_file3.is_none()); +} + +/// Test SSH config resolve_jump_host_connection +#[test] +fn test_ssh_config_resolve_jump_host_connection() { + let ssh_config_content = r#" +Host bastion + HostName bastion.example.com + User jumpuser + Port 2222 + IdentityFile ~/.ssh/bastion_key + +Host gateway + HostName gateway.example.com + +Host nohost + User someuser +"#; + + let ssh_config = SshConfig::parse(ssh_config_content).expect("Failed to parse SSH config"); + + // Test full connection string + let result = ssh_config.resolve_jump_host_connection("bastion"); + assert!(result.is_some()); + let (conn_str, identity_file) = result.unwrap(); + assert_eq!(conn_str, "jumpuser@bastion.example.com:2222"); + assert!(identity_file.is_some()); + + // Test without user or port + let result2 = ssh_config.resolve_jump_host_connection("gateway"); + assert!(result2.is_some()); + let (conn_str2, identity_file2) = result2.unwrap(); + assert_eq!(conn_str2, "gateway.example.com"); + assert!(identity_file2.is_none()); + + // Test with user but no HostName (uses alias as hostname) + let result3 = ssh_config.resolve_jump_host_connection("nohost"); + assert!(result3.is_some()); + let (conn_str3, _) = result3.unwrap(); + assert_eq!(conn_str3, "someuser@nohost"); +} + +/// Test config resolution with SSH config reference +#[test] +fn test_config_jump_host_ssh_config_resolution() { + let yaml = r#" +clusters: + test: + nodes: + - host: node1 + jump_host: "@bastion" +"#; + + let config: Config = serde_yaml::from_str(yaml).expect("Failed to parse config"); + + let ssh_config_content = r#" +Host bastion + HostName bastion.example.com + User jumpuser + Port 2222 + IdentityFile ~/.ssh/bastion_key +"#; + let ssh_config = SshConfig::parse(ssh_config_content).expect("Failed to parse SSH config"); + + // Without SSH config, resolution returns alias as hostname + let (conn_str, ssh_key) = config + .get_jump_host_with_key("test", 0) + .expect("Jump host not found"); + assert_eq!(conn_str, "bastion"); + assert!(ssh_key.is_none()); + + // With SSH config, full resolution works + let (conn_str2, ssh_key2) = config + .get_jump_host_with_key_and_ssh_config("test", 0, Some(&ssh_config)) + .expect("Jump host not found"); + assert_eq!(conn_str2, "jumpuser@bastion.example.com:2222"); + assert!(ssh_key2.is_some()); + assert!(ssh_key2.as_ref().unwrap().contains("bastion_key")); +} + +/// Test config resolution with SshConfigHostRef variant +#[test] +fn test_config_jump_host_ssh_config_host_ref_resolution() { + let yaml = r#" +clusters: + test: + nodes: + - host: node1 + jump_host: + ssh_config_host: gateway +"#; + + let config: Config = serde_yaml::from_str(yaml).expect("Failed to parse config"); + + let ssh_config_content = r#" +Host gateway + HostName gateway.internal.com + User admin + IdentityFile ~/.ssh/gateway_key +"#; + let ssh_config = SshConfig::parse(ssh_config_content).expect("Failed to parse SSH config"); + + let (conn_str, ssh_key) = config + .get_jump_host_with_key_and_ssh_config("test", 0, Some(&ssh_config)) + .expect("Jump host not found"); + + assert_eq!(conn_str, "admin@gateway.internal.com"); + assert!(ssh_key.is_some()); + assert!(ssh_key.as_ref().unwrap().contains("gateway_key")); +} + +/// Test node-level SSH config reference override +#[test] +fn test_node_level_ssh_config_ref_override() { + let yaml = r#" +clusters: + test: + nodes: + - host: special-node + jump_host: "@special-bastion" + - host: normal-node + jump_host: "@default-bastion" +"#; + + let config: Config = serde_yaml::from_str(yaml).expect("Failed to parse config"); + + let ssh_config_content = r#" +Host default-bastion + HostName default.example.com + User defaultuser + +Host special-bastion + HostName special.example.com + User specialuser + Port 3333 +"#; + let ssh_config = SshConfig::parse(ssh_config_content).expect("Failed to parse SSH config"); + + // Node 0 uses special-bastion + let (conn_str0, _) = config + .get_jump_host_with_key_and_ssh_config("test", 0, Some(&ssh_config)) + .expect("Jump host not found"); + assert_eq!(conn_str0, "specialuser@special.example.com:3333"); + + // Node 1 uses default-bastion + let (conn_str1, _) = config + .get_jump_host_with_key_and_ssh_config("test", 1, Some(&ssh_config)) + .expect("Jump host not found"); + assert_eq!(conn_str1, "defaultuser@default.example.com"); +} + +/// Test global default SSH config reference +#[test] +fn test_global_default_ssh_config_ref() { + let yaml = r#" +defaults: + jump_host: + ssh_config_host: global-bastion + +clusters: + cluster_a: + nodes: + - host: a1.internal + + cluster_b: + nodes: + - host: b1.internal + jump_host: direct-bastion.example.com +"#; + + let config: Config = serde_yaml::from_str(yaml).expect("Failed to parse config"); + + let ssh_config_content = r#" +Host global-bastion + HostName global.example.com + User globaluser +"#; + let ssh_config = SshConfig::parse(ssh_config_content).expect("Failed to parse SSH config"); + + // Cluster A should use global SSH config reference + let (conn_str_a, _) = config + .get_cluster_jump_host_with_key_and_ssh_config(Some("cluster_a"), Some(&ssh_config)) + .expect("Jump host not found"); + assert_eq!(conn_str_a, "globaluser@global.example.com"); + + // Cluster B has its own explicit jump_host, not SSH config ref + let (conn_str_b, _) = config + .get_cluster_jump_host_with_key_and_ssh_config(Some("cluster_b"), Some(&ssh_config)) + .expect("Jump host not found"); + assert_eq!(conn_str_b, "direct-bastion.example.com"); +} + +/// Test fallback when SSH config alias doesn't exist +#[test] +fn test_ssh_config_ref_nonexistent_fallback() { + let yaml = r#" +clusters: + test: + nodes: + - host: node1 + jump_host: "@nonexistent-bastion" +"#; + + let config: Config = serde_yaml::from_str(yaml).expect("Failed to parse config"); + + let ssh_config_content = r#" +Host some-other-host + HostName other.example.com +"#; + let ssh_config = SshConfig::parse(ssh_config_content).expect("Failed to parse SSH config"); + + // Should fallback to using alias as hostname when not found + let (conn_str, ssh_key) = config + .get_jump_host_with_key_and_ssh_config("test", 0, Some(&ssh_config)) + .expect("Jump host not found"); + assert_eq!(conn_str, "nonexistent-bastion"); + assert!(ssh_key.is_none()); +} + +/// Test backward compatibility: all formats work together +#[test] +fn test_all_jump_host_formats_together() { + let yaml = r#" +clusters: + legacy: + nodes: + - host: node1 + jump_host: user@direct.example.com:2222 + + structured: + nodes: + - host: node2 + jump_host: + host: structured.example.com + user: admin + ssh_key: ~/.ssh/structured_key + + ssh_config_simple: + nodes: + - host: node3 + jump_host: "@bastion" + + ssh_config_structured: + nodes: + - host: node4 + jump_host: + ssh_config_host: gateway +"#; + + let config: Config = serde_yaml::from_str(yaml).expect("Failed to parse config"); + + let ssh_config_content = r#" +Host bastion + HostName bastion.resolved.com + User bastionuser + +Host gateway + HostName gateway.resolved.com + User gatewayuser + IdentityFile ~/.ssh/gateway_key +"#; + let ssh_config = SshConfig::parse(ssh_config_content).expect("Failed to parse SSH config"); + + // Legacy format + let (conn_str1, _) = config + .get_cluster_jump_host_with_key_and_ssh_config(Some("legacy"), Some(&ssh_config)) + .expect("Jump host not found"); + assert_eq!(conn_str1, "user@direct.example.com:2222"); + + // Structured format + let (conn_str2, key2) = config + .get_cluster_jump_host_with_key_and_ssh_config(Some("structured"), Some(&ssh_config)) + .expect("Jump host not found"); + assert_eq!(conn_str2, "admin@structured.example.com"); + assert!(key2.is_some()); + + // SSH config simple (@alias) + let (conn_str3, _) = config + .get_cluster_jump_host_with_key_and_ssh_config(Some("ssh_config_simple"), Some(&ssh_config)) + .expect("Jump host not found"); + assert_eq!(conn_str3, "bastionuser@bastion.resolved.com"); + + // SSH config structured (ssh_config_host) + let (conn_str4, key4) = config + .get_cluster_jump_host_with_key_and_ssh_config( + Some("ssh_config_structured"), + Some(&ssh_config), + ) + .expect("Jump host not found"); + assert_eq!(conn_str4, "gatewayuser@gateway.resolved.com"); + assert!(key4.is_some()); +}