Skip to content

Commit 6e39056

Browse files
committed
feat: Support per-jump-host SSH private key configuration
Add support for configuring separate SSH keys for jump hosts independent of destination node keys. Jump hosts can now use structured YAML format with optional ssh_key field while maintaining backward compatibility with string format. Key changes: - Add ssh_key field to JumpHost struct - Create JumpHostConfig enum supporting Simple(String) and Detailed formats - Update config resolver with get_jump_host_with_key methods - Implement SSH key priority: jump host key > cluster key > agent > defaults - Add comprehensive tests for config parsing and auth priority - Update documentation and example config
1 parent cf06f10 commit 6e39056

9 files changed

Lines changed: 719 additions & 34 deletions

File tree

docs/architecture/ssh-jump-hosts.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,99 @@ clusters:
440440
2. SSH config `ProxyJump` directive
441441
3. YAML config (node → cluster → global)
442442

443+
### Per-Jump-Host SSH Key Configuration (Issue #167 - Implemented)
444+
445+
**Implementation:** `src/config/types.rs`, `src/jump/chain/auth.rs`, `src/jump/parser/host.rs`
446+
447+
Jump hosts can now specify their own SSH private keys, separate from the destination node keys.
448+
449+
**Configuration Format:**
450+
451+
Supports both legacy string format and new structured format:
452+
453+
```yaml
454+
clusters:
455+
internal:
456+
nodes:
457+
- host: internal1.private
458+
- host: internal2.private
459+
user: admin
460+
ssh_key: ~/.ssh/destination_key # For destination nodes
461+
462+
# Legacy string format (uses cluster ssh_key for jump host)
463+
jump_host: jumpuser@bastion.example.com
464+
465+
# OR new structured format with dedicated jump host key:
466+
jump_host:
467+
host: bastion.example.com
468+
user: jumpuser
469+
port: 22 # optional
470+
ssh_key: ~/.ssh/jump_host_key # Jump host's own key
471+
```
472+
473+
**Per-Node Jump Host Override:**
474+
475+
```yaml
476+
clusters:
477+
hybrid:
478+
nodes:
479+
- host: behind-firewall.internal
480+
jump_host:
481+
host: gateway.example.com
482+
user: gw_user
483+
ssh_key: ~/.ssh/gateway_key # Specific key for this gateway
484+
- host: direct-access.example.com
485+
jump_host: "" # Direct connection
486+
jump_host: default-bastion.example.com
487+
```
488+
489+
**SSH Key Priority Order:**
490+
491+
When authenticating to jump hosts, the following priority is used:
492+
493+
1. **Jump host's own `ssh_key`** (from structured config)
494+
2. **Cluster/defaults `ssh_key`** (fallback)
495+
3. **SSH agent** (if use_agent=true and agent has keys)
496+
4. **Default key files** (~/.ssh/id_*)
497+
498+
**Implementation Details:**
499+
500+
- `JumpHost` struct now has `ssh_key: Option<String>` field
501+
- `JumpHostConfig` enum supports both `Simple(String)` and `Detailed { host, user, port, ssh_key }`
502+
- `#[serde(untagged)]` enables seamless deserialization of both formats
503+
- Environment variable expansion works in `ssh_key` paths (e.g., `$HOME/.ssh/key`)
504+
- Path expansion supports `~` tilde notation
505+
506+
**Example Use Case:**
507+
508+
```yaml
509+
clusters:
510+
secure:
511+
nodes:
512+
- host: db.internal
513+
user: dbadmin
514+
ssh_key: ~/.ssh/db_admin_key # For database access
515+
jump_host:
516+
host: bastion.example.com
517+
user: bastion_user
518+
ssh_key: ~/.ssh/bastion_key # Separate key for bastion
519+
```
520+
521+
**Backward Compatibility:**
522+
523+
- All existing configurations continue to work without changes
524+
- String format `jump_host: "user@host:port"` still supported
525+
- When no `ssh_key` is specified in jump_host config, falls back to cluster `ssh_key`
526+
- Multi-hop chains work with mixed formats
527+
528+
**Tests:**
529+
530+
- Unit tests in `tests/jump_host_config_test.rs`
531+
- Auth priority tests in `src/jump/chain/auth.rs::tests`
532+
- Validates both simple and structured format deserialization
533+
- Verifies environment variable expansion
534+
- Confirms backward compatibility
535+
443536
### Future Enhancements
444537

445538
1. **Jump Host Connection Pooling:**

example-config.yaml

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,28 @@ clusters:
5757
- host: internal2.private
5858
- host: internal3.private
5959
user: admin # User for internal*.private (destination nodes)
60-
jump_host: jumpuser@bastion.example.com # User 'jumpuser' for bastion (jump host)
61-
# Alternative: jump_host: bastion.example.com # Uses your local username for bastion
60+
ssh_key: ~/.ssh/destination_key # Key for destination nodes
61+
# Legacy string format (uses cluster ssh_key for both jump host and destinations)
62+
jump_host: jumpuser@bastion.example.com
63+
# Alternative structured format with dedicated jump host key:
64+
# jump_host:
65+
# host: bastion.example.com
66+
# user: jumpuser
67+
# port: 22 # optional
68+
# ssh_key: ~/.ssh/jump_host_key # Uses this key for bastion only
6269

63-
# Example: Mixed direct and jump host access
70+
# Example: Mixed direct and jump host access with per-node jump host override
6471
hybrid:
6572
nodes:
6673
- host: behind-firewall.internal
67-
jump_host: gateway.example.com # Needs jump host
74+
# Per-node jump host with dedicated key
75+
jump_host:
76+
host: gateway.example.com
77+
user: gw_user
78+
ssh_key: ~/.ssh/gateway_key
6879
- host: direct-access.example.com
6980
jump_host: "" # Empty string disables jump host (direct connection)
70-
jump_host: default-bastion.example.com # Default for cluster
81+
jump_host: default-bastion.example.com # Default for cluster (string format)
7182

7283
# Example: Multi-hop jump chain with environment variables
7384
secure:

src/config/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@ mod utils;
2525
// Re-export public types
2626
pub use types::{
2727
Cluster, ClusterDefaults, Config, Defaults, InteractiveConfig, InteractiveConfigUpdate,
28-
InteractiveMode, KeyBindings, NodeConfig,
28+
InteractiveMode, JumpHostConfig, KeyBindings, NodeConfig,
2929
};
30-
pub use utils::expand_tilde;
30+
pub use utils::{expand_env_vars, expand_tilde};

src/config/resolver.rs

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -133,32 +133,80 @@ impl Config {
133133
///
134134
/// Empty string (`""`) explicitly disables jump host inheritance.
135135
pub fn get_jump_host(&self, cluster_name: &str, node_index: usize) -> Option<String> {
136+
self.get_jump_host_with_key(cluster_name, node_index)
137+
.map(|(conn_str, _)| conn_str)
138+
}
139+
140+
/// Get jump host with SSH key for a specific node in a cluster.
141+
///
142+
/// Resolution priority (highest to lowest):
143+
/// 1. Node-level `jump_host` (in `NodeConfig::Detailed`)
144+
/// 2. Cluster-level `jump_host` (in `ClusterDefaults`)
145+
/// 3. Global default `jump_host` (in `Defaults`)
146+
///
147+
/// Empty string (`""`) explicitly disables jump host inheritance.
148+
/// Returns tuple of (connection_string, optional_ssh_key_path)
149+
pub fn get_jump_host_with_key(
150+
&self,
151+
cluster_name: &str,
152+
node_index: usize,
153+
) -> Option<(String, Option<String>)> {
136154
if let Some(cluster) = self.get_cluster(cluster_name) {
137155
// Check node-level first
138156
if let Some(NodeConfig::Detailed {
139157
jump_host: Some(jh),
140158
..
141159
}) = cluster.nodes.get(node_index)
142160
{
143-
if jh.is_empty() {
144-
return None; // Explicitly disabled
145-
}
146-
return Some(expand_env_vars(jh));
161+
return self.process_jump_host_config(jh);
147162
}
148163
// Check cluster-level
149164
if let Some(jh) = &cluster.defaults.jump_host {
150-
if jh.is_empty() {
151-
return None; // Explicitly disabled
152-
}
153-
return Some(expand_env_vars(jh));
165+
return self.process_jump_host_config(jh);
154166
}
155167
}
156168
// Fall back to global default
157169
self.defaults
158170
.jump_host
159171
.as_ref()
160-
.filter(|s| !s.is_empty())
161-
.map(|s| expand_env_vars(s))
172+
.and_then(|jh| self.process_jump_host_config(jh))
173+
}
174+
175+
/// Process a JumpHostConfig and return (connection_string, optional_ssh_key_path)
176+
fn process_jump_host_config(
177+
&self,
178+
config: &super::types::JumpHostConfig,
179+
) -> Option<(String, Option<String>)> {
180+
use super::types::JumpHostConfig;
181+
182+
match config {
183+
JumpHostConfig::Simple(s) => {
184+
if s.is_empty() {
185+
None // Explicitly disabled
186+
} else {
187+
Some((expand_env_vars(s), None))
188+
}
189+
}
190+
JumpHostConfig::Detailed {
191+
host,
192+
user,
193+
port,
194+
ssh_key,
195+
} => {
196+
let mut conn_str = String::new();
197+
if let Some(u) = user {
198+
conn_str.push_str(&expand_env_vars(u));
199+
conn_str.push('@');
200+
}
201+
conn_str.push_str(&expand_env_vars(host));
202+
if let Some(p) = port {
203+
conn_str.push(':');
204+
conn_str.push_str(&p.to_string());
205+
}
206+
let key = ssh_key.as_ref().map(|k| expand_env_vars(k));
207+
Some((conn_str, key))
208+
}
209+
}
162210
}
163211

164212
/// Get jump host for a cluster (cluster-level default).
@@ -169,22 +217,34 @@ impl Config {
169217
///
170218
/// Empty string (`""`) explicitly disables jump host inheritance.
171219
pub fn get_cluster_jump_host(&self, cluster_name: Option<&str>) -> Option<String> {
220+
self.get_cluster_jump_host_with_key(cluster_name)
221+
.map(|(conn_str, _)| conn_str)
222+
}
223+
224+
/// Get jump host with SSH key for a cluster (cluster-level default).
225+
///
226+
/// Resolution priority (highest to lowest):
227+
/// 1. Cluster-level `jump_host` (in `ClusterDefaults`)
228+
/// 2. Global default `jump_host` (in `Defaults`)
229+
///
230+
/// Empty string (`""`) explicitly disables jump host inheritance.
231+
/// Returns tuple of (connection_string, optional_ssh_key_path)
232+
pub fn get_cluster_jump_host_with_key(
233+
&self,
234+
cluster_name: Option<&str>,
235+
) -> Option<(String, Option<String>)> {
172236
if let Some(cluster_name) = cluster_name {
173237
if let Some(cluster) = self.get_cluster(cluster_name) {
174238
if let Some(jh) = &cluster.defaults.jump_host {
175-
if jh.is_empty() {
176-
return None; // Explicitly disabled
177-
}
178-
return Some(expand_env_vars(jh));
239+
return self.process_jump_host_config(jh);
179240
}
180241
}
181242
}
182243
// Fall back to global default
183244
self.defaults
184245
.jump_host
185246
.as_ref()
186-
.filter(|s| !s.is_empty())
187-
.map(|s| expand_env_vars(s))
247+
.and_then(|jh| self.process_jump_host_config(jh))
188248
}
189249

190250
/// Get SSH keepalive interval for a cluster.

src/config/types.rs

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,28 @@ pub struct Config {
3030
pub interactive: InteractiveConfig,
3131
}
3232

33+
/// Jump host configuration format.
34+
///
35+
/// Supports both legacy string format and structured format with optional SSH key.
36+
/// Uses `#[serde(untagged)]` to allow seamless deserialization of both formats.
37+
#[derive(Debug, Serialize, Deserialize, Clone)]
38+
#[serde(untagged)]
39+
pub enum JumpHostConfig {
40+
/// Structured format with optional ssh_key field
41+
/// Must be listed first for serde to try matching object format before string
42+
Detailed {
43+
host: String,
44+
#[serde(default)]
45+
user: Option<String>,
46+
#[serde(default)]
47+
port: Option<u16>,
48+
#[serde(default)]
49+
ssh_key: Option<String>,
50+
},
51+
/// Legacy string format: "[user@]hostname[:port]"
52+
Simple(String),
53+
}
54+
3355
/// Global default settings.
3456
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
3557
pub struct Defaults {
@@ -39,8 +61,9 @@ pub struct Defaults {
3961
pub parallel: Option<usize>,
4062
pub timeout: Option<u64>,
4163
/// Jump host specification for all connections.
64+
/// Supports both string format and structured format with optional ssh_key.
4265
/// Empty string explicitly disables jump host inheritance.
43-
pub jump_host: Option<String>,
66+
pub jump_host: Option<JumpHostConfig>,
4467
/// SSH keepalive interval in seconds.
4568
/// Sends keepalive packets to prevent idle connection timeouts.
4669
/// Default: 60 seconds. Set to 0 to disable.
@@ -128,8 +151,9 @@ pub struct ClusterDefaults {
128151
pub parallel: Option<usize>,
129152
pub timeout: Option<u64>,
130153
/// Jump host specification for this cluster.
154+
/// Supports both string format and structured format with optional ssh_key.
131155
/// Empty string explicitly disables jump host inheritance.
132-
pub jump_host: Option<String>,
156+
pub jump_host: Option<JumpHostConfig>,
133157
/// SSH keepalive interval in seconds.
134158
/// Sends keepalive packets to prevent idle connection timeouts.
135159
/// Default: 60 seconds. Set to 0 to disable.
@@ -151,9 +175,10 @@ pub enum NodeConfig {
151175
#[serde(default)]
152176
user: Option<String>,
153177
/// Jump host specification for this node.
178+
/// Supports both string format and structured format with optional ssh_key.
154179
/// Empty string explicitly disables jump host inheritance.
155180
#[serde(default)]
156-
jump_host: Option<String>,
181+
jump_host: Option<JumpHostConfig>,
157182
},
158183
}
159184

@@ -188,3 +213,38 @@ pub(super) fn default_broadcast_toggle() -> String {
188213
pub(super) fn default_quit() -> String {
189214
"Ctrl+Q".to_string()
190215
}
216+
217+
impl JumpHostConfig {
218+
/// Convert to a connection string for resolution
219+
pub fn to_connection_string(&self) -> String {
220+
match self {
221+
JumpHostConfig::Simple(s) => s.clone(),
222+
JumpHostConfig::Detailed {
223+
host,
224+
user,
225+
port,
226+
ssh_key: _,
227+
} => {
228+
let mut result = String::new();
229+
if let Some(u) = user {
230+
result.push_str(u);
231+
result.push('@');
232+
}
233+
result.push_str(host);
234+
if let Some(p) = port {
235+
result.push(':');
236+
result.push_str(&p.to_string());
237+
}
238+
result
239+
}
240+
}
241+
}
242+
243+
/// Get the SSH key path if specified
244+
pub fn ssh_key(&self) -> Option<&str> {
245+
match self {
246+
JumpHostConfig::Simple(_) => None,
247+
JumpHostConfig::Detailed { ssh_key, .. } => ssh_key.as_deref(),
248+
}
249+
}
250+
}

0 commit comments

Comments
 (0)