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
93 changes: 93 additions & 0 deletions docs/architecture/ssh-jump-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,99 @@ clusters:
2. SSH config `ProxyJump` directive
3. YAML config (node → cluster → global)

### Per-Jump-Host SSH Key Configuration (Issue #167 - Implemented)

**Implementation:** `src/config/types.rs`, `src/jump/chain/auth.rs`, `src/jump/parser/host.rs`

Jump hosts can now specify their own SSH private keys, separate from the destination node keys.

**Configuration Format:**

Supports both legacy string format and new structured format:

```yaml
clusters:
internal:
nodes:
- host: internal1.private
- host: internal2.private
user: admin
ssh_key: ~/.ssh/destination_key # For destination nodes

# Legacy string format (uses cluster ssh_key for jump host)
jump_host: [email protected]

# OR new structured format with dedicated jump host key:
jump_host:
host: bastion.example.com
user: jumpuser
port: 22 # optional
ssh_key: ~/.ssh/jump_host_key # Jump host's own key
```

**Per-Node Jump Host Override:**

```yaml
clusters:
hybrid:
nodes:
- host: behind-firewall.internal
jump_host:
host: gateway.example.com
user: gw_user
ssh_key: ~/.ssh/gateway_key # Specific key for this gateway
- host: direct-access.example.com
jump_host: "" # Direct connection
jump_host: default-bastion.example.com
```

**SSH Key Priority Order:**

When authenticating to jump hosts, the following priority is used:

1. **Jump host's own `ssh_key`** (from structured config)
2. **Cluster/defaults `ssh_key`** (fallback)
3. **SSH agent** (if use_agent=true and agent has keys)
4. **Default key files** (~/.ssh/id_*)

**Implementation Details:**

- `JumpHost` struct now has `ssh_key: Option<String>` field
- `JumpHostConfig` enum supports both `Simple(String)` and `Detailed { host, user, port, ssh_key }`
- `#[serde(untagged)]` enables seamless deserialization of both formats
- Environment variable expansion works in `ssh_key` paths (e.g., `$HOME/.ssh/key`)
- Path expansion supports `~` tilde notation

**Example Use Case:**

```yaml
clusters:
secure:
nodes:
- host: db.internal
user: dbadmin
ssh_key: ~/.ssh/db_admin_key # For database access
jump_host:
host: bastion.example.com
user: bastion_user
ssh_key: ~/.ssh/bastion_key # Separate key for bastion
```

**Backward Compatibility:**

- All existing configurations continue to work without changes
- String format `jump_host: "user@host:port"` still supported
- When no `ssh_key` is specified in jump_host config, falls back to cluster `ssh_key`
- Multi-hop chains work with mixed formats

**Tests:**

- Unit tests in `tests/jump_host_config_test.rs`
- Auth priority tests in `src/jump/chain/auth.rs::tests`
- Validates both simple and structured format deserialization
- Verifies environment variable expansion
- Confirms backward compatibility

### Future Enhancements

1. **Jump Host Connection Pooling:**
Expand Down
21 changes: 16 additions & 5 deletions example-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,28 @@ clusters:
- host: internal2.private
- host: internal3.private
user: admin # User for internal*.private (destination nodes)
jump_host: [email protected] # User 'jumpuser' for bastion (jump host)
# Alternative: jump_host: bastion.example.com # Uses your local username for bastion
ssh_key: ~/.ssh/destination_key # Key for destination nodes
# Legacy string format (uses cluster ssh_key for both jump host and destinations)
jump_host: [email protected]
# Alternative structured format with dedicated jump host key:
# jump_host:
# host: bastion.example.com
# user: jumpuser
# port: 22 # optional
# ssh_key: ~/.ssh/jump_host_key # Uses this key for bastion only

# Example: Mixed direct and jump host access
# Example: Mixed direct and jump host access with per-node jump host override
hybrid:
nodes:
- host: behind-firewall.internal
jump_host: gateway.example.com # Needs jump host
# Per-node jump host with dedicated key
jump_host:
host: gateway.example.com
user: gw_user
ssh_key: ~/.ssh/gateway_key
- host: direct-access.example.com
jump_host: "" # Empty string disables jump host (direct connection)
jump_host: default-bastion.example.com # Default for cluster
jump_host: default-bastion.example.com # Default for cluster (string format)

# Example: Multi-hop jump chain with environment variables
secure:
Expand Down
4 changes: 2 additions & 2 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ mod utils;
// Re-export public types
pub use types::{
Cluster, ClusterDefaults, Config, Defaults, InteractiveConfig, InteractiveConfigUpdate,
InteractiveMode, KeyBindings, NodeConfig,
InteractiveMode, JumpHostConfig, KeyBindings, NodeConfig,
};
pub use utils::expand_tilde;
pub use utils::{expand_env_vars, expand_tilde};
92 changes: 76 additions & 16 deletions src/config/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,32 +133,80 @@ impl Config {
///
/// Empty string (`""`) explicitly disables jump host inheritance.
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)
}

/// Get jump host with SSH key for a specific node in a cluster.
///
/// 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(
&self,
cluster_name: &str,
node_index: usize,
) -> Option<(String, Option<String>)> {
if let Some(cluster) = self.get_cluster(cluster_name) {
// Check node-level first
if let Some(NodeConfig::Detailed {
jump_host: Some(jh),
..
}) = cluster.nodes.get(node_index)
{
if jh.is_empty() {
return None; // Explicitly disabled
}
return Some(expand_env_vars(jh));
return self.process_jump_host_config(jh);
}
// Check cluster-level
if let Some(jh) = &cluster.defaults.jump_host {
if jh.is_empty() {
return None; // Explicitly disabled
}
return Some(expand_env_vars(jh));
return self.process_jump_host_config(jh);
}
}
// Fall back to global default
self.defaults
.jump_host
.as_ref()
.filter(|s| !s.is_empty())
.map(|s| expand_env_vars(s))
.and_then(|jh| self.process_jump_host_config(jh))
}

/// Process a JumpHostConfig and return (connection_string, optional_ssh_key_path)
fn process_jump_host_config(
&self,
config: &super::types::JumpHostConfig,
) -> Option<(String, Option<String>)> {
use super::types::JumpHostConfig;

match config {
JumpHostConfig::Simple(s) => {
if s.is_empty() {
None // Explicitly disabled
} else {
Some((expand_env_vars(s), None))
}
}
JumpHostConfig::Detailed {
host,
user,
port,
ssh_key,
} => {
let mut conn_str = String::new();
if let Some(u) = user {
conn_str.push_str(&expand_env_vars(u));
conn_str.push('@');
}
conn_str.push_str(&expand_env_vars(host));
if let Some(p) = port {
conn_str.push(':');
conn_str.push_str(&p.to_string());
}
let key = ssh_key.as_ref().map(|k| expand_env_vars(k));
Some((conn_str, key))
}
}
}

/// Get jump host for a cluster (cluster-level default).
Expand All @@ -169,22 +217,34 @@ impl Config {
///
/// Empty string (`""`) explicitly disables jump host inheritance.
pub fn get_cluster_jump_host(&self, cluster_name: Option<&str>) -> Option<String> {
self.get_cluster_jump_host_with_key(cluster_name)
.map(|(conn_str, _)| conn_str)
}

/// Get jump host with SSH key for a cluster (cluster-level default).
///
/// 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(
&self,
cluster_name: Option<&str>,
) -> 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 {
if jh.is_empty() {
return None; // Explicitly disabled
}
return Some(expand_env_vars(jh));
return self.process_jump_host_config(jh);
}
}
}
// Fall back to global default
self.defaults
.jump_host
.as_ref()
.filter(|s| !s.is_empty())
.map(|s| expand_env_vars(s))
.and_then(|jh| self.process_jump_host_config(jh))
}

/// Get SSH keepalive interval for a cluster.
Expand Down
66 changes: 63 additions & 3 deletions src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,28 @@ pub struct Config {
pub interactive: InteractiveConfig,
}

/// 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.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum JumpHostConfig {
/// Structured format with optional ssh_key field
/// Must be listed first for serde to try matching object format before string
Detailed {
host: String,
#[serde(default)]
user: Option<String>,
#[serde(default)]
port: Option<u16>,
#[serde(default)]
ssh_key: Option<String>,
},
/// Legacy string format: "[user@]hostname[:port]"
Simple(String),
}

/// Global default settings.
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct Defaults {
Expand All @@ -39,8 +61,9 @@ pub struct Defaults {
pub parallel: Option<usize>,
pub timeout: Option<u64>,
/// Jump host specification for all connections.
/// Supports both string format and structured format with optional ssh_key.
/// Empty string explicitly disables jump host inheritance.
pub jump_host: Option<String>,
pub jump_host: Option<JumpHostConfig>,
/// SSH keepalive interval in seconds.
/// Sends keepalive packets to prevent idle connection timeouts.
/// Default: 60 seconds. Set to 0 to disable.
Expand Down Expand Up @@ -128,8 +151,9 @@ pub struct ClusterDefaults {
pub parallel: Option<usize>,
pub timeout: Option<u64>,
/// Jump host specification for this cluster.
/// Supports both string format and structured format with optional ssh_key.
/// Empty string explicitly disables jump host inheritance.
pub jump_host: Option<String>,
pub jump_host: Option<JumpHostConfig>,
/// SSH keepalive interval in seconds.
/// Sends keepalive packets to prevent idle connection timeouts.
/// Default: 60 seconds. Set to 0 to disable.
Expand All @@ -151,9 +175,10 @@ pub enum NodeConfig {
#[serde(default)]
user: Option<String>,
/// Jump host specification for this node.
/// Supports both string format and structured format with optional ssh_key.
/// Empty string explicitly disables jump host inheritance.
#[serde(default)]
jump_host: Option<String>,
jump_host: Option<JumpHostConfig>,
},
}

Expand Down Expand Up @@ -188,3 +213,38 @@ pub(super) fn default_broadcast_toggle() -> String {
pub(super) fn default_quit() -> String {
"Ctrl+Q".to_string()
}

impl JumpHostConfig {
/// Convert to a connection string for resolution
pub fn to_connection_string(&self) -> String {
match self {
JumpHostConfig::Simple(s) => s.clone(),
JumpHostConfig::Detailed {
host,
user,
port,
ssh_key: _,
} => {
let mut result = String::new();
if let Some(u) = user {
result.push_str(u);
result.push('@');
}
result.push_str(host);
if let Some(p) = port {
result.push(':');
result.push_str(&p.to_string());
}
result
}
}
}

/// Get the SSH key path if specified
pub fn ssh_key(&self) -> Option<&str> {
match self {
JumpHostConfig::Simple(_) => None,
JumpHostConfig::Detailed { ssh_key, .. } => ssh_key.as_deref(),
}
}
}
Loading
Loading