Skip to content

Commit 10d377f

Browse files
authored
feat: Support per-jump-host SSH private key configuration (#169)
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 10d377f

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)