Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion example-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,39 @@ clusters:
secure:
nodes:
- host: target.secure.internal
jump_host: ${FIRST_HOP},${SECOND_HOP} # Comma-separated for multi-hop
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: ""
100 changes: 91 additions & 9 deletions src/config/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> {
self.get_jump_host_with_key(cluster_name, node_index)
.map(|(conn_str, _)| conn_str)
Expand All @@ -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<String>)> {
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<String>)> {
if let Some(cluster) = self.get_cluster(cluster_name) {
// Check node-level first
Expand All @@ -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<String>)> {
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))
}
Expand All @@ -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<String>)> {
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):
Expand All @@ -232,19 +298,35 @@ impl Config {
pub fn get_cluster_jump_host_with_key(
&self,
cluster_name: Option<&str>,
) -> Option<(String, Option<String>)> {
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<String>)> {
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);
}
}
}
// 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))
}

/// Get SSH keepalive interval for a cluster.
Expand Down
50 changes: 46 additions & 4 deletions src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -49,6 +59,7 @@ pub enum JumpHostConfig {
ssh_key: Option<String>,
},
/// Legacy string format: "[user@]hostname[:port]"
/// Also supports SSH config reference with "@" prefix: "@alias"
Simple(String),
}

Expand Down Expand Up @@ -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(),
Expand All @@ -237,6 +252,9 @@ impl JumpHostConfig {
}
result
}
JumpHostConfig::SshConfigHostRef { ssh_config_host } => {
format!("@{}", ssh_config_host)
}
}
}

Expand All @@ -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,
}
}
}
Loading
Loading