Skip to content

Commit b2be72c

Browse files
authored
feat: Support SSH config Host alias reference in jump_host configuration (#171)
Add support for referencing SSH config Host aliases in bssh's jump_host configuration. This allows users to leverage existing SSH config entries with all their settings (HostName, User, Port, IdentityFile). New formats supported: - Simple string with @ prefix: `jump_host: "@Bastion"` - Structured format: `jump_host: { ssh_config_host: "bastion" }` When an SSH config reference is used, bssh resolves the alias from ~/.ssh/config and extracts: - HostName (or uses alias as hostname if not specified) - User - Port - IdentityFile (first one, used as SSH key for the jump host) Changes: - Add SshConfigHostRef variant to JumpHostConfig enum - Add is_ssh_config_ref() and ssh_config_host() methods to JumpHostConfig - Add resolve_jump_host() and resolve_jump_host_connection() to SshConfig - Add get_jump_host_with_key_and_ssh_config() for full SSH config resolution - Update example-config.yaml with SSH config reference examples - Add 15 new tests for SSH config reference functionality Closes #170
1 parent 10d377f commit b2be72c

File tree

5 files changed

+739
-15
lines changed

5 files changed

+739
-15
lines changed

example-config.yaml

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,39 @@ clusters:
8484
secure:
8585
nodes:
8686
- host: target.secure.internal
87-
jump_host: ${FIRST_HOP},${SECOND_HOP} # Comma-separated for multi-hop
87+
jump_host: ${FIRST_HOP},${SECOND_HOP} # Comma-separated for multi-hop
88+
89+
# Example: Using SSH config Host alias as jump host
90+
# This references a Host defined in ~/.ssh/config, inheriting its settings:
91+
# - HostName, User, Port, and IdentityFile are all read from SSH config
92+
#
93+
# ~/.ssh/config example:
94+
# Host my-bastion
95+
# HostName bastion.example.com
96+
# User jumpuser
97+
# Port 2222
98+
# IdentityFile ~/.ssh/bastion_key
99+
#
100+
ssh_config_ref:
101+
nodes:
102+
- host: target.internal
103+
# Simple format with @ prefix references SSH config Host alias
104+
jump_host: "@my-bastion"
105+
106+
# Alternative structured format for SSH config reference
107+
ssh_config_ref_structured:
108+
nodes:
109+
- host: target2.internal
110+
jump_host:
111+
ssh_config_host: my-bastion # References SSH config Host alias
112+
113+
# Example: Per-node SSH config references
114+
mixed_ssh_config:
115+
nodes:
116+
- host: node1.internal
117+
jump_host: "@bastion-zone-a" # Different SSH config entry per node
118+
- host: node2.internal
119+
jump_host: "@bastion-zone-b"
120+
- host: node3.internal
121+
# Direct connection, no jump host
122+
jump_host: ""

src/config/resolver.rs

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
use anyhow::Result;
1818

1919
use crate::node::Node;
20+
use crate::ssh::ssh_config::SshConfig;
2021

21-
use super::types::{Cluster, Config, NodeConfig};
22+
use super::types::{Cluster, Config, JumpHostConfig, NodeConfig};
2223
use super::utils::{expand_env_vars, get_current_username};
2324

2425
impl Config {
@@ -132,6 +133,9 @@ impl Config {
132133
/// 3. Global default `jump_host` (in `Defaults`)
133134
///
134135
/// Empty string (`""`) explicitly disables jump host inheritance.
136+
///
137+
/// Note: This method does not resolve SSH config references (`@alias`).
138+
/// Use `get_jump_host_with_key_and_ssh_config` for full resolution.
135139
pub fn get_jump_host(&self, cluster_name: &str, node_index: usize) -> Option<String> {
136140
self.get_jump_host_with_key(cluster_name, node_index)
137141
.map(|(conn_str, _)| conn_str)
@@ -146,10 +150,34 @@ impl Config {
146150
///
147151
/// Empty string (`""`) explicitly disables jump host inheritance.
148152
/// Returns tuple of (connection_string, optional_ssh_key_path)
153+
///
154+
/// Note: This method does not resolve SSH config references (`@alias`).
155+
/// Use `get_jump_host_with_key_and_ssh_config` for full resolution.
149156
pub fn get_jump_host_with_key(
150157
&self,
151158
cluster_name: &str,
152159
node_index: usize,
160+
) -> Option<(String, Option<String>)> {
161+
self.get_jump_host_with_key_and_ssh_config(cluster_name, node_index, None)
162+
}
163+
164+
/// Get jump host with SSH key for a specific node, with SSH config reference resolution.
165+
///
166+
/// This is the full-featured version that can resolve SSH config Host alias references
167+
/// (`@alias` or `ssh_config_host` field) using the provided SSH config.
168+
///
169+
/// Resolution priority (highest to lowest):
170+
/// 1. Node-level `jump_host` (in `NodeConfig::Detailed`)
171+
/// 2. Cluster-level `jump_host` (in `ClusterDefaults`)
172+
/// 3. Global default `jump_host` (in `Defaults`)
173+
///
174+
/// Empty string (`""`) explicitly disables jump host inheritance.
175+
/// Returns tuple of (connection_string, optional_ssh_key_path)
176+
pub fn get_jump_host_with_key_and_ssh_config(
177+
&self,
178+
cluster_name: &str,
179+
node_index: usize,
180+
ssh_config: Option<&SshConfig>,
153181
) -> Option<(String, Option<String>)> {
154182
if let Some(cluster) = self.get_cluster(cluster_name) {
155183
// Check node-level first
@@ -158,31 +186,36 @@ impl Config {
158186
..
159187
}) = cluster.nodes.get(node_index)
160188
{
161-
return self.process_jump_host_config(jh);
189+
return self.process_jump_host_config(jh, ssh_config);
162190
}
163191
// Check cluster-level
164192
if let Some(jh) = &cluster.defaults.jump_host {
165-
return self.process_jump_host_config(jh);
193+
return self.process_jump_host_config(jh, ssh_config);
166194
}
167195
}
168196
// Fall back to global default
169197
self.defaults
170198
.jump_host
171199
.as_ref()
172-
.and_then(|jh| self.process_jump_host_config(jh))
200+
.and_then(|jh| self.process_jump_host_config(jh, ssh_config))
173201
}
174202

175203
/// Process a JumpHostConfig and return (connection_string, optional_ssh_key_path)
204+
///
205+
/// If `ssh_config` is provided, SSH config references (`@alias` or `ssh_config_host`)
206+
/// will be resolved using the SSH config. Otherwise, the reference string is returned as-is.
176207
fn process_jump_host_config(
177208
&self,
178-
config: &super::types::JumpHostConfig,
209+
config: &JumpHostConfig,
210+
ssh_config: Option<&SshConfig>,
179211
) -> Option<(String, Option<String>)> {
180-
use super::types::JumpHostConfig;
181-
182212
match config {
183213
JumpHostConfig::Simple(s) => {
184214
if s.is_empty() {
185215
None // Explicitly disabled
216+
} else if let Some(alias) = s.strip_prefix('@') {
217+
// SSH config reference with @ prefix
218+
self.resolve_ssh_config_jump_host(alias, ssh_config)
186219
} else {
187220
Some((expand_env_vars(s), None))
188221
}
@@ -206,9 +239,42 @@ impl Config {
206239
let key = ssh_key.as_ref().map(|k| expand_env_vars(k));
207240
Some((conn_str, key))
208241
}
242+
JumpHostConfig::SshConfigHostRef { ssh_config_host } => {
243+
self.resolve_ssh_config_jump_host(ssh_config_host, ssh_config)
244+
}
209245
}
210246
}
211247

248+
/// Resolve an SSH config Host alias to connection string and SSH key.
249+
///
250+
/// If `ssh_config` is provided, looks up the alias and extracts:
251+
/// - HostName (or uses the alias as hostname)
252+
/// - User
253+
/// - Port
254+
/// - IdentityFile (first one, used as SSH key)
255+
///
256+
/// If `ssh_config` is None, returns the alias as the hostname with no SSH key.
257+
fn resolve_ssh_config_jump_host(
258+
&self,
259+
alias: &str,
260+
ssh_config: Option<&SshConfig>,
261+
) -> Option<(String, Option<String>)> {
262+
if let Some(ssh_cfg) = ssh_config {
263+
// Try to resolve from SSH config
264+
if let Some((conn_str, identity_file)) = ssh_cfg.resolve_jump_host_connection(alias) {
265+
return Some((conn_str, identity_file));
266+
}
267+
}
268+
269+
// Fallback: use the alias as the hostname (SSH will resolve it)
270+
// This allows the connection to proceed even without explicit SSH config resolution
271+
tracing::debug!(
272+
"SSH config reference '{}' could not be resolved, using as hostname",
273+
alias
274+
);
275+
Some((alias.to_string(), None))
276+
}
277+
212278
/// Get jump host for a cluster (cluster-level default).
213279
///
214280
/// Resolution priority (highest to lowest):
@@ -232,19 +298,35 @@ impl Config {
232298
pub fn get_cluster_jump_host_with_key(
233299
&self,
234300
cluster_name: Option<&str>,
301+
) -> Option<(String, Option<String>)> {
302+
self.get_cluster_jump_host_with_key_and_ssh_config(cluster_name, None)
303+
}
304+
305+
/// Get jump host with SSH key for a cluster, with SSH config reference resolution.
306+
///
307+
/// Resolution priority (highest to lowest):
308+
/// 1. Cluster-level `jump_host` (in `ClusterDefaults`)
309+
/// 2. Global default `jump_host` (in `Defaults`)
310+
///
311+
/// Empty string (`""`) explicitly disables jump host inheritance.
312+
/// Returns tuple of (connection_string, optional_ssh_key_path)
313+
pub fn get_cluster_jump_host_with_key_and_ssh_config(
314+
&self,
315+
cluster_name: Option<&str>,
316+
ssh_config: Option<&SshConfig>,
235317
) -> Option<(String, Option<String>)> {
236318
if let Some(cluster_name) = cluster_name {
237319
if let Some(cluster) = self.get_cluster(cluster_name) {
238320
if let Some(jh) = &cluster.defaults.jump_host {
239-
return self.process_jump_host_config(jh);
321+
return self.process_jump_host_config(jh, ssh_config);
240322
}
241323
}
242324
}
243325
// Fall back to global default
244326
self.defaults
245327
.jump_host
246328
.as_ref()
247-
.and_then(|jh| self.process_jump_host_config(jh))
329+
.and_then(|jh| self.process_jump_host_config(jh, ssh_config))
248330
}
249331

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

src/config/types.rs

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,23 @@ pub struct Config {
3232

3333
/// Jump host configuration format.
3434
///
35-
/// Supports both legacy string format and structured format with optional SSH key.
36-
/// Uses `#[serde(untagged)]` to allow seamless deserialization of both formats.
35+
/// Supports multiple formats:
36+
/// - Legacy string format: `"[user@]hostname[:port]"`
37+
/// - SSH config reference: `"@alias"` (references ~/.ssh/config Host alias)
38+
/// - Structured format with optional ssh_key
39+
/// - Structured SSH config reference with ssh_config_host field
40+
///
41+
/// Uses `#[serde(untagged)]` to allow seamless deserialization of all formats.
3742
#[derive(Debug, Serialize, Deserialize, Clone)]
3843
#[serde(untagged)]
3944
pub enum JumpHostConfig {
40-
/// Structured format with optional ssh_key field
45+
/// Structured SSH config reference format with ssh_config_host field
4146
/// Must be listed first for serde to try matching object format before string
47+
SshConfigHostRef {
48+
/// SSH config Host alias to reference (from ~/.ssh/config)
49+
ssh_config_host: String,
50+
},
51+
/// Structured format with optional ssh_key field
4252
Detailed {
4353
host: String,
4454
#[serde(default)]
@@ -49,6 +59,7 @@ pub enum JumpHostConfig {
4959
ssh_key: Option<String>,
5060
},
5161
/// Legacy string format: "[user@]hostname[:port]"
62+
/// Also supports SSH config reference with "@" prefix: "@alias"
5263
Simple(String),
5364
}
5465

@@ -215,7 +226,11 @@ pub(super) fn default_quit() -> String {
215226
}
216227

217228
impl JumpHostConfig {
218-
/// Convert to a connection string for resolution
229+
/// Convert to a connection string for resolution.
230+
///
231+
/// Note: For SSH config references (`@alias` or `ssh_config_host`), this returns
232+
/// the alias name with "@" prefix. The actual resolution to hostname/user/port
233+
/// must be done by the caller using SSH config parsing.
219234
pub fn to_connection_string(&self) -> String {
220235
match self {
221236
JumpHostConfig::Simple(s) => s.clone(),
@@ -237,6 +252,9 @@ impl JumpHostConfig {
237252
}
238253
result
239254
}
255+
JumpHostConfig::SshConfigHostRef { ssh_config_host } => {
256+
format!("@{}", ssh_config_host)
257+
}
240258
}
241259
}
242260

@@ -245,6 +263,30 @@ impl JumpHostConfig {
245263
match self {
246264
JumpHostConfig::Simple(_) => None,
247265
JumpHostConfig::Detailed { ssh_key, .. } => ssh_key.as_deref(),
266+
JumpHostConfig::SshConfigHostRef { .. } => None,
267+
}
268+
}
269+
270+
/// Check if this is an SSH config reference (either `@alias` string or `ssh_config_host` field)
271+
pub fn is_ssh_config_ref(&self) -> bool {
272+
match self {
273+
JumpHostConfig::Simple(s) => s.starts_with('@'),
274+
JumpHostConfig::SshConfigHostRef { .. } => true,
275+
JumpHostConfig::Detailed { .. } => false,
276+
}
277+
}
278+
279+
/// Get the SSH config host alias if this is an SSH config reference.
280+
///
281+
/// Returns the alias name (without "@" prefix) for:
282+
/// - `JumpHostConfig::Simple("@alias")` -> Some("alias")
283+
/// - `JumpHostConfig::SshConfigHostRef { ssh_config_host: "alias" }` -> Some("alias")
284+
/// - Other variants -> None
285+
pub fn ssh_config_host(&self) -> Option<&str> {
286+
match self {
287+
JumpHostConfig::Simple(s) if s.starts_with('@') => Some(&s[1..]),
288+
JumpHostConfig::SshConfigHostRef { ssh_config_host } => Some(ssh_config_host),
289+
_ => None,
248290
}
249291
}
250292
}

0 commit comments

Comments
 (0)