feat: add SSH config aliases and PostgreSQL extensions
This commit is contained in:
1 parent
8b035d0989
commit
f800d80f90
35 files changed
+1191
-33
No files matched your search
@@ -911,6 +911,7 @@ mod tests {
|
||||
expose_lan: false,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
auth_method: "password".to_string(),
|
||||
}),
|
||||
TransportLayerConfig::HttpTunnel(crate::models::connection::HttpTunnelConfig {
|
||||
id: "http".to_string(),
|
||||
|
||||
@@ -780,6 +780,7 @@ mod tests {
|
||||
expose_lan: false,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
auth_method: "key".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ use crate::sql::starts_with_executable_sql_keyword;
|
||||
use crate::types::{
|
||||
ColumnInfo, CompletionAssistantCandidate, CompletionAssistantCandidateKind, CompletionAssistantMatchMode,
|
||||
CompletionAssistantObjectKind, CompletionAssistantRequest, CompletionAssistantResponse, DatabaseInfo,
|
||||
ForeignKeyInfo, FunctionInfo, IndexInfo, ObjectInfo, ObjectStatistics, OwnerInfo, QueryResult, RuleInfo,
|
||||
SchemaInfo, SequenceInfo, TableInfo, TriggerInfo,
|
||||
ExtensionInfo, ForeignKeyInfo, FunctionInfo, IndexInfo, ObjectInfo, ObjectStatistics, OwnerInfo, QueryResult,
|
||||
RuleInfo, SchemaInfo, SequenceInfo, TableInfo, TriggerInfo,
|
||||
};
|
||||
|
||||
fn pg_temporal_to_json_value(row: &Row, idx: usize) -> Option<serde_json::Value> {
|
||||
@@ -2845,6 +2845,56 @@ pub async fn list_rules(pool: &Pool, schema: &str) -> Result<Vec<RuleInfo>, Stri
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_extensions(pool: &Pool, schema: &str) -> Result<Vec<ExtensionInfo>, String> {
|
||||
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
|
||||
let stmt = client
|
||||
.prepare_cached(
|
||||
"SELECT e.extname, COALESCE(e.extversion, '') AS extversion, d.description, n.nspname \
|
||||
FROM pg_catalog.pg_extension e \
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace \
|
||||
LEFT JOIN pg_catalog.pg_description d ON d.objoid = e.oid AND d.classoid = 'pg_extension'::regclass \
|
||||
WHERE n.nspname = $1 \
|
||||
ORDER BY e.extname",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = client.query(&stmt, &[&schema]).await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| ExtensionInfo {
|
||||
name: row.get::<_, String>(0),
|
||||
version: row.get::<_, String>(1),
|
||||
comment: row.try_get::<_, Option<String>>(2).ok().flatten().filter(|s| !s.is_empty()),
|
||||
schema: Some(schema.to_string()),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_available_extensions(pool: &Pool) -> Result<Vec<ExtensionInfo>, String> {
|
||||
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
|
||||
let stmt = client
|
||||
.prepare_cached(
|
||||
"SELECT name, default_version, comment \
|
||||
FROM pg_catalog.pg_available_extensions \
|
||||
WHERE installed_version IS NULL \
|
||||
ORDER BY name",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = client.query(&stmt, &[]).await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| ExtensionInfo {
|
||||
name: row.get::<_, String>(0),
|
||||
version: row.get::<_, String>(1),
|
||||
comment: row.try_get::<_, Option<String>>(2).ok().flatten().filter(|s| !s.is_empty()),
|
||||
schema: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_owners(pool: &Pool, schema: &str) -> Result<Vec<OwnerInfo>, String> {
|
||||
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
|
||||
let stmt = client.prepare_cached(POSTGRES_OWNERS_SQL).await.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
use crate::path_utils::expand_tilde;
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
@@ -66,6 +68,7 @@ async fn connect_and_authenticate(
|
||||
ssh_key_passphrase: &str,
|
||||
use_ssh_agent: bool,
|
||||
ssh_agent_sock_path: &str,
|
||||
auth_method: &str,
|
||||
connect_timeout_secs: u64,
|
||||
) -> Result<Handle<SshClient>, String> {
|
||||
let config = Arc::new(ssh_client_config());
|
||||
@@ -88,8 +91,19 @@ async fn connect_and_authenticate(
|
||||
return Ok(session);
|
||||
}
|
||||
|
||||
// When auth_method is "none" and the probe was rejected, fail early
|
||||
// instead of falling back to other credential methods.
|
||||
if auth_method == "none" {
|
||||
return Err("SSH authentication failed: server rejected the connection without credentials".to_string());
|
||||
}
|
||||
|
||||
// "none" was rejected — fall back to the configured credential method.
|
||||
if !ssh_key_path.is_empty() {
|
||||
// When auth_method is set, only try the matching method.
|
||||
let try_key = auth_method.is_empty() && !ssh_key_path.is_empty() || auth_method == "key";
|
||||
let try_password = auth_method.is_empty() && !ssh_password.is_empty() || auth_method == "password";
|
||||
let try_agent = auth_method.is_empty() && use_ssh_agent || auth_method == "agent";
|
||||
|
||||
if try_key {
|
||||
// Validate SSH key file path
|
||||
validate_file_path(ssh_key_path, |_| false)?;
|
||||
|
||||
@@ -112,7 +126,7 @@ async fn connect_and_authenticate(
|
||||
if !auth_res.success() {
|
||||
return Err("SSH public key authentication failed".to_string());
|
||||
}
|
||||
} else if !ssh_password.is_empty() {
|
||||
} else if try_password {
|
||||
let auth_res = tokio::time::timeout(connect_timeout, session.authenticate_password(ssh_user, ssh_password))
|
||||
.await
|
||||
.map_err(|_| format!("SSH password auth timed out ({connect_timeout_secs}s)"))?
|
||||
@@ -120,7 +134,7 @@ async fn connect_and_authenticate(
|
||||
if !auth_res.success() {
|
||||
return Err("SSH password authentication failed".to_string());
|
||||
}
|
||||
} else if use_ssh_agent {
|
||||
} else if try_agent {
|
||||
match try_authenticate_with_agent(&mut session, ssh_user, ssh_agent_sock_path, &connect_timeout).await {
|
||||
Ok(()) => {}
|
||||
Err(agent_err) => return Err(agent_err),
|
||||
@@ -215,7 +229,8 @@ async fn try_authenticate_with_agent(
|
||||
}
|
||||
|
||||
fn load_ssh_private_key(path: &str, passphrase: Option<&str>) -> Result<PrivateKey, String> {
|
||||
let secret = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
let expanded = expand_tilde(path);
|
||||
let secret = fs::read_to_string(&expanded).map_err(|e| e.to_string())?;
|
||||
match decode_secret_key(&secret, passphrase) {
|
||||
Ok(key) => Ok(key),
|
||||
Err(err) if is_ssh_key_character_encoding_error(&err.to_string()) => {
|
||||
@@ -460,6 +475,7 @@ async fn tunnel_reconnect_loop(
|
||||
ssh_key_passphrase: String,
|
||||
use_ssh_agent: bool,
|
||||
ssh_agent_sock_path: String,
|
||||
auth_method: String,
|
||||
connect_timeout_secs: u64,
|
||||
listener: TcpListener,
|
||||
remote_host: String,
|
||||
@@ -495,6 +511,7 @@ async fn tunnel_reconnect_loop(
|
||||
&ssh_key_passphrase,
|
||||
use_ssh_agent,
|
||||
&ssh_agent_sock_path,
|
||||
&auth_method,
|
||||
connect_timeout_secs,
|
||||
)
|
||||
.await
|
||||
@@ -565,6 +582,7 @@ impl TunnelManager {
|
||||
ssh_key_passphrase: &str,
|
||||
use_ssh_agent: bool,
|
||||
ssh_agent_sock_path: &str,
|
||||
auth_method: &str,
|
||||
connect_timeout_secs: u64,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
@@ -588,6 +606,7 @@ impl TunnelManager {
|
||||
ssh_key_passphrase,
|
||||
use_ssh_agent,
|
||||
ssh_agent_sock_path,
|
||||
auth_method,
|
||||
connect_timeout_secs,
|
||||
remote_host,
|
||||
remote_port,
|
||||
@@ -658,6 +677,7 @@ impl TunnelManager {
|
||||
&hop.key_passphrase,
|
||||
hop.use_ssh_agent,
|
||||
&hop.ssh_agent_sock_path,
|
||||
&hop.auth_method,
|
||||
effective_hop_timeout(hop),
|
||||
&target_host,
|
||||
target_port,
|
||||
@@ -718,6 +738,7 @@ async fn spawn_tunnel(
|
||||
ssh_key_passphrase: &str,
|
||||
use_ssh_agent: bool,
|
||||
ssh_agent_sock_path: &str,
|
||||
auth_method: &str,
|
||||
connect_timeout_secs: u64,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
@@ -739,6 +760,7 @@ async fn spawn_tunnel(
|
||||
ssh_key_passphrase,
|
||||
use_ssh_agent,
|
||||
ssh_agent_sock_path,
|
||||
auth_method,
|
||||
connect_timeout_secs,
|
||||
)
|
||||
.await?;
|
||||
@@ -753,6 +775,7 @@ async fn spawn_tunnel(
|
||||
ssh_key_passphrase.to_string(),
|
||||
use_ssh_agent,
|
||||
ssh_agent_sock_path.to_string(),
|
||||
auth_method.to_string(),
|
||||
connect_timeout_secs,
|
||||
listener,
|
||||
remote_host.to_string(),
|
||||
@@ -851,6 +874,7 @@ mod tests {
|
||||
expose_lan: false,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
auth_method: "password".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,25 @@ impl LayerEndpoint {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves any `~/.ssh/config` aliases on SSH layers before endpoints are
|
||||
/// computed. Both `.endpoint()` read sites below (the current layer's own
|
||||
/// connect target, and an earlier layer's forward target when this layer is
|
||||
/// the *next* one in the chain) must see the resolved host/port rather than
|
||||
/// a literal alias string, so resolution happens once up front instead of
|
||||
/// only at the `start_tunnel` call site.
|
||||
fn resolve_ssh_layers(
|
||||
layers: &[TransportLayerConfig],
|
||||
resolve: impl Fn(&crate::models::connection::SshTunnelConfig) -> crate::models::connection::SshTunnelConfig,
|
||||
) -> Vec<TransportLayerConfig> {
|
||||
layers
|
||||
.iter()
|
||||
.map(|layer| match layer {
|
||||
TransportLayerConfig::Ssh(ssh) => TransportLayerConfig::Ssh(resolve(ssh)),
|
||||
other => other.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Starts an ordered transport layer chain and returns the final local port.
|
||||
///
|
||||
/// Each layer listens on a local port. The next layer connects to that local
|
||||
@@ -34,6 +53,9 @@ pub async fn start_transport_layers(
|
||||
}
|
||||
validate_transport_layers(layers)?;
|
||||
|
||||
let layers = resolve_ssh_layers(layers, crate::ssh_config::resolve_ssh_tunnel_config);
|
||||
let layers = layers.as_slice();
|
||||
|
||||
let mut next_connect_endpoint: Option<LayerEndpoint> = None;
|
||||
let mut final_local_port = 0;
|
||||
|
||||
@@ -52,21 +74,22 @@ pub async fn start_transport_layers(
|
||||
};
|
||||
|
||||
let local_port = match layer {
|
||||
TransportLayerConfig::Ssh(ssh) => ssh_tunnels
|
||||
TransportLayerConfig::Ssh(resolved) => ssh_tunnels
|
||||
.start_tunnel(
|
||||
&layer_id,
|
||||
&connect_endpoint.host,
|
||||
connect_endpoint.port,
|
||||
&ssh.user,
|
||||
&ssh.password,
|
||||
&ssh.key_path,
|
||||
&ssh.key_passphrase,
|
||||
ssh.use_ssh_agent,
|
||||
&ssh.ssh_agent_sock_path,
|
||||
effective_ssh_connect_timeout_secs(ssh.connect_timeout_secs),
|
||||
&resolved.user,
|
||||
&resolved.password,
|
||||
&resolved.key_path,
|
||||
&resolved.key_passphrase,
|
||||
resolved.use_ssh_agent,
|
||||
&resolved.ssh_agent_sock_path,
|
||||
&resolved.auth_method,
|
||||
effective_ssh_connect_timeout_secs(resolved.connect_timeout_secs),
|
||||
&target_endpoint.host,
|
||||
target_endpoint.port,
|
||||
is_last && ssh.expose_lan,
|
||||
is_last && resolved.expose_lan,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("SSH layer {} failed: {err}", index + 1))?,
|
||||
@@ -164,6 +187,22 @@ fn plan_transport_layers(
|
||||
remote_port: u16,
|
||||
local_ports: &[u16],
|
||||
) -> Vec<PlannedTransportLayer> {
|
||||
plan_transport_layers_with_resolver(layers, remote_host, remote_port, local_ports, |ssh| ssh.clone())
|
||||
}
|
||||
|
||||
/// Same as `plan_transport_layers`, but takes an explicit SSH-alias resolver
|
||||
/// so tests can exercise `~/.ssh/config` resolution without touching the real
|
||||
/// filesystem (mirrors `start_transport_layers`'s use of `resolve_ssh_layers`).
|
||||
#[cfg(test)]
|
||||
fn plan_transport_layers_with_resolver(
|
||||
layers: &[TransportLayerConfig],
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
local_ports: &[u16],
|
||||
resolve: impl Fn(&crate::models::connection::SshTunnelConfig) -> crate::models::connection::SshTunnelConfig,
|
||||
) -> Vec<PlannedTransportLayer> {
|
||||
let layers = resolve_ssh_layers(layers, resolve);
|
||||
let layers = layers.as_slice();
|
||||
let mut planned = Vec::new();
|
||||
let mut next_connect_endpoint: Option<(String, u16)> = None;
|
||||
for (index, layer) in layers.iter().enumerate() {
|
||||
@@ -198,7 +237,10 @@ fn plan_transport_layers(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{plan_transport_layers, validate_transport_layers, PlannedLayerType, PlannedTransportLayer};
|
||||
use super::{
|
||||
plan_transport_layers, plan_transport_layers_with_resolver, validate_transport_layers, PlannedLayerType,
|
||||
PlannedTransportLayer,
|
||||
};
|
||||
use crate::models::connection::{
|
||||
HttpTunnelConfig, ProxyTunnelConfig, ProxyType, SshTunnelConfig, TransportLayerConfig,
|
||||
};
|
||||
@@ -218,6 +260,7 @@ mod tests {
|
||||
expose_lan: false,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
auth_method: "password".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -295,6 +338,71 @@ mod tests {
|
||||
assert!(err.contains("HTTP tunnel must be the first transport layer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_ssh_hop_with_config_alias_connects_to_resolved_host_and_port() {
|
||||
// RF-001 regression: a single SSH layer whose `host` is a
|
||||
// `~/.ssh/config` alias must dial the resolved HostName/Port, not
|
||||
// the literal alias string.
|
||||
let layers = vec![ssh_layer("ssh-a", "myserver", 22)];
|
||||
|
||||
let planned = plan_transport_layers_with_resolver(&layers, "db.internal", 5432, &[], |ssh| {
|
||||
assert_eq!(ssh.host, "myserver");
|
||||
let mut resolved = ssh.clone();
|
||||
resolved.host = "10.0.0.5".to_string();
|
||||
resolved.port = 2222;
|
||||
resolved
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
planned,
|
||||
vec![PlannedTransportLayer {
|
||||
layer_type: PlannedLayerType::Ssh,
|
||||
connect_host: "10.0.0.5".to_string(),
|
||||
connect_port: 2222,
|
||||
remote_host: "db.internal".to_string(),
|
||||
remote_port: 5432,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earlier_hop_forwards_to_resolved_alias_of_next_ssh_hop() {
|
||||
// RF-002 regression: when hop N+1 is an SSH layer addressed by a
|
||||
// config alias, hop N's forward target must be the resolved
|
||||
// HostName/Port, not the literal alias (which the remote host at
|
||||
// hop N cannot resolve).
|
||||
let layers = vec![ssh_layer("ssh-a", "bastion-a", 22), ssh_layer("ssh-b", "myserver", 22)];
|
||||
|
||||
let planned = plan_transport_layers_with_resolver(&layers, "db.internal", 5432, &[41001], |ssh| {
|
||||
let mut resolved = ssh.clone();
|
||||
if ssh.host == "myserver" {
|
||||
resolved.host = "10.0.0.5".to_string();
|
||||
resolved.port = 2222;
|
||||
}
|
||||
resolved
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
planned,
|
||||
vec![
|
||||
PlannedTransportLayer {
|
||||
layer_type: PlannedLayerType::Ssh,
|
||||
connect_host: "bastion-a".to_string(),
|
||||
connect_port: 22,
|
||||
remote_host: "10.0.0.5".to_string(),
|
||||
remote_port: 2222,
|
||||
},
|
||||
PlannedTransportLayer {
|
||||
layer_type: PlannedLayerType::Ssh,
|
||||
connect_host: "127.0.0.1".to_string(),
|
||||
connect_port: 41001,
|
||||
remote_host: "db.internal".to_string(),
|
||||
remote_port: 5432,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_tunnel_first_layer_targets_next_layer() {
|
||||
let layers = vec![
|
||||
|
||||
@@ -51,6 +51,7 @@ pub mod sql_file_import;
|
||||
pub mod sql_risk;
|
||||
pub mod sqlite_backup;
|
||||
pub(crate) mod sqlserver_temporal;
|
||||
pub mod ssh_config;
|
||||
pub mod storage;
|
||||
pub mod table_export;
|
||||
pub mod table_import;
|
||||
|
||||
@@ -166,6 +166,13 @@ pub struct SshTunnelConfig {
|
||||
/// the `SSH_AUTH_SOCK` environment variable.
|
||||
#[serde(default)]
|
||||
pub ssh_agent_sock_path: String,
|
||||
/// Login method: `"password"`, `"key"`, `"agent"`, or `"none"`.
|
||||
/// Empty string means an older saved connection predating this field —
|
||||
/// the backend falls back to probing key > password > agent based on
|
||||
/// which fields are non-empty. When set to a specific method the backend
|
||||
/// only tries that method (after the standard `none` probe).
|
||||
#[serde(default)]
|
||||
pub auth_method: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
||||
@@ -4073,6 +4073,43 @@ pub async fn list_rules_core(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_extensions_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
schema: &str,
|
||||
) -> Result<Vec<db::ExtensionInfo>, String> {
|
||||
retry_metadata_connection(state, connection_id, Some(database), || async {
|
||||
let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?;
|
||||
let connections = state.connections.read().await;
|
||||
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
|
||||
|
||||
match pool {
|
||||
PoolKind::Postgres(p) => db::postgres::list_extensions(p, schema).await,
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_available_extensions_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
) -> Result<Vec<db::ExtensionInfo>, String> {
|
||||
retry_metadata_connection(state, connection_id, Some(database), || async {
|
||||
let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?;
|
||||
let connections = state.connections.read().await;
|
||||
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
|
||||
|
||||
match pool {
|
||||
PoolKind::Postgres(p) => db::postgres::list_available_extensions(p).await,
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_owners_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::models::connection::SshTunnelConfig;
|
||||
use crate::path_utils::expand_tilde;
|
||||
|
||||
/// Sentinel values the frontend fills in when the user leaves a field blank
|
||||
/// (see `normalizeSshTunnel` / `defaultSshTunnel` in ConnectionDialog.vue).
|
||||
/// There is no way to distinguish "user explicitly typed 22" from "field was
|
||||
/// left empty and defaulted to 22", so we treat these as "unset" for the
|
||||
/// purpose of filling in values from `~/.ssh/config`.
|
||||
const DEFAULT_USER_SENTINEL: &str = "root";
|
||||
const DEFAULT_PORT_SENTINEL: u16 = 22;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct SshConfigHostEntry {
|
||||
pub alias: String,
|
||||
pub host_name: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub user: Option<String>,
|
||||
pub identity_file: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads and parses `~/.ssh/config`. Returns an empty list (not an error) if
|
||||
/// the file does not exist, since that's a normal state for users without
|
||||
/// an SSH config.
|
||||
pub fn list_hosts() -> Result<Vec<SshConfigHostEntry>, String> {
|
||||
let path = expand_tilde("~/.ssh/config");
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => Ok(parse_ssh_config(&content)),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
||||
Err(err) => Err(format!("Failed to read {path}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_host(alias: &str) -> Option<SshConfigHostEntry> {
|
||||
list_hosts().ok()?.into_iter().find(|entry| entry.alias == alias)
|
||||
}
|
||||
|
||||
/// Fills in `host`, `user`, `port`, and `key_path` from a matching `~/.ssh/config`
|
||||
/// `Host` block, without overwriting values the user has explicitly set.
|
||||
///
|
||||
/// Only `ssh.host` is matched against config aliases; `user`/`port`/`key_path`
|
||||
/// are filled in from that same matched entry. Values already present on
|
||||
/// `ssh` win, except for `user`/`port` which use the sentinel defaults above
|
||||
/// to detect "not actually set by the user".
|
||||
pub fn resolve_ssh_tunnel_config(ssh: &SshTunnelConfig) -> SshTunnelConfig {
|
||||
match find_host(&ssh.host) {
|
||||
Some(entry) => apply_host_entry(ssh, entry),
|
||||
None => ssh.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a resolved `~/.ssh/config` entry onto `ssh`, without overwriting
|
||||
/// values the user has explicitly set. `user`/`port` use the sentinel
|
||||
/// defaults above to detect "not actually set by the user".
|
||||
fn apply_host_entry(ssh: &SshTunnelConfig, entry: SshConfigHostEntry) -> SshTunnelConfig {
|
||||
let mut resolved = ssh.clone();
|
||||
|
||||
if let Some(host_name) = entry.host_name {
|
||||
resolved.host = host_name;
|
||||
}
|
||||
if resolved.user == DEFAULT_USER_SENTINEL {
|
||||
if let Some(user) = entry.user {
|
||||
resolved.user = user;
|
||||
}
|
||||
}
|
||||
if resolved.port == DEFAULT_PORT_SENTINEL {
|
||||
if let Some(port) = entry.port {
|
||||
resolved.port = port;
|
||||
}
|
||||
}
|
||||
if resolved.key_path.is_empty() {
|
||||
if let Some(identity_file) = entry.identity_file {
|
||||
resolved.key_path = identity_file;
|
||||
// If the SSH config supplied the only usable credential, make the
|
||||
// backend use it even when an older/default UI payload still says
|
||||
// "password" with an empty password.
|
||||
if resolved.auth_method.is_empty() || (resolved.auth_method == "password" && resolved.password.is_empty()) {
|
||||
resolved.auth_method = "key".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolved
|
||||
}
|
||||
|
||||
/// Parses a minimal subset of OpenSSH client config syntax: `Host`, `HostName`,
|
||||
/// `Port`, `User`, `IdentityFile`. Wildcard host patterns (containing `*` or
|
||||
/// `?`) are skipped since they aren't usable as a literal alias in the host
|
||||
/// field. `Include` and other directives are not supported.
|
||||
fn parse_ssh_config(content: &str) -> Vec<SshConfigHostEntry> {
|
||||
let mut entries: Vec<SshConfigHostEntry> = Vec::new();
|
||||
let mut current_aliases: Vec<String> = Vec::new();
|
||||
|
||||
for raw_line in content.lines() {
|
||||
let line = strip_comment(raw_line).trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((keyword, value)) = split_directive(line) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match keyword.to_ascii_lowercase().as_str() {
|
||||
"host" => {
|
||||
current_aliases = value
|
||||
.split_whitespace()
|
||||
.filter(|alias| !alias.contains('*') && !alias.contains('?'))
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
for alias in ¤t_aliases {
|
||||
entries.push(SshConfigHostEntry {
|
||||
alias: alias.clone(),
|
||||
host_name: None,
|
||||
port: None,
|
||||
user: None,
|
||||
identity_file: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
"hostname" => set_current_field(&mut entries, ¤t_aliases, |entry| {
|
||||
entry.host_name = Some(value.to_string());
|
||||
}),
|
||||
"port" => {
|
||||
if let Ok(port) = value.parse::<u16>() {
|
||||
set_current_field(&mut entries, ¤t_aliases, |entry| {
|
||||
entry.port = Some(port);
|
||||
});
|
||||
}
|
||||
}
|
||||
"user" => set_current_field(&mut entries, ¤t_aliases, |entry| {
|
||||
entry.user = Some(value.to_string());
|
||||
}),
|
||||
"identityfile" => set_current_field(&mut entries, ¤t_aliases, |entry| {
|
||||
entry.identity_file = Some(value.to_string());
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn set_current_field(
|
||||
entries: &mut [SshConfigHostEntry],
|
||||
current_aliases: &[String],
|
||||
apply: impl Fn(&mut SshConfigHostEntry),
|
||||
) {
|
||||
for entry in entries.iter_mut() {
|
||||
if current_aliases.contains(&entry.alias) {
|
||||
apply(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_comment(line: &str) -> &str {
|
||||
match line.find('#') {
|
||||
Some(index) => &line[..index],
|
||||
None => line,
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a config line into `(keyword, value)`. OpenSSH allows the keyword
|
||||
/// and value to be separated by whitespace or a single `=`.
|
||||
fn split_directive(line: &str) -> Option<(&str, &str)> {
|
||||
let line = line.trim();
|
||||
let split_index = line.find(|c: char| c.is_whitespace() || c == '=')?;
|
||||
let keyword = &line[..split_index];
|
||||
let value = line[split_index..].trim_start_matches(|c: char| c.is_whitespace() || c == '=').trim();
|
||||
if keyword.is_empty() || value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((keyword, value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config(host: &str) -> SshTunnelConfig {
|
||||
SshTunnelConfig {
|
||||
id: "1".to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
host: host.to_string(),
|
||||
port: DEFAULT_PORT_SENTINEL,
|
||||
user: DEFAULT_USER_SENTINEL.to_string(),
|
||||
password: String::new(),
|
||||
key_path: String::new(),
|
||||
key_passphrase: String::new(),
|
||||
connect_timeout_secs: 5,
|
||||
expose_lan: false,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
auth_method: "password".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_basic_host_block() {
|
||||
let entries = parse_ssh_config(
|
||||
"Host myserver\n HostName 10.0.0.5\n Port 2222\n User deploy\n IdentityFile ~/.ssh/id_ed25519\n",
|
||||
);
|
||||
assert_eq!(entries.len(), 1);
|
||||
let entry = &entries[0];
|
||||
assert_eq!(entry.alias, "myserver");
|
||||
assert_eq!(entry.host_name, Some("10.0.0.5".to_string()));
|
||||
assert_eq!(entry.port, Some(2222));
|
||||
assert_eq!(entry.user, Some("deploy".to_string()));
|
||||
assert_eq!(entry.identity_file, Some("~/.ssh/id_ed25519".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_line_can_declare_multiple_aliases() {
|
||||
let entries = parse_ssh_config("Host prod prod-alias\n HostName 10.0.0.9\n");
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert!(entries.iter().all(|entry| entry.host_name == Some("10.0.0.9".to_string())));
|
||||
assert_eq!(entries[0].alias, "prod");
|
||||
assert_eq!(entries[1].alias, "prod-alias");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_wildcard_host_patterns() {
|
||||
let entries = parse_ssh_config("Host *.example.com\n User git\nHost real\n User deploy\n");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].alias, "real");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_comments_and_blank_lines() {
|
||||
let entries = parse_ssh_config("# a comment\n\nHost myserver # inline comment\n User deploy\n");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].user, Some("deploy".to_string()));
|
||||
}
|
||||
|
||||
fn entry(alias: &str) -> SshConfigHostEntry {
|
||||
SshConfigHostEntry {
|
||||
alias: alias.to_string(),
|
||||
host_name: Some("10.0.0.5".to_string()),
|
||||
port: Some(2222),
|
||||
user: Some("deploy".to_string()),
|
||||
identity_file: Some("~/.ssh/id_ed25519".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_fills_unset_fields_from_matching_alias() {
|
||||
let ssh = config("myserver");
|
||||
let resolved = apply_host_entry(&ssh, entry("myserver"));
|
||||
assert_eq!(resolved.host, "10.0.0.5");
|
||||
assert_eq!(resolved.port, 2222);
|
||||
assert_eq!(resolved.user, "deploy");
|
||||
assert_eq!(resolved.key_path, "~/.ssh/id_ed25519");
|
||||
assert_eq!(resolved.auth_method, "key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_keeps_password_auth_when_password_is_present() {
|
||||
let mut ssh = config("myserver");
|
||||
ssh.password = "secret".to_string();
|
||||
let resolved = apply_host_entry(&ssh, entry("myserver"));
|
||||
assert_eq!(resolved.key_path, "~/.ssh/id_ed25519");
|
||||
assert_eq!(resolved.auth_method, "password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_does_not_override_explicit_values() {
|
||||
let mut ssh = config("myserver");
|
||||
ssh.user = "alice".to_string();
|
||||
ssh.port = 9999;
|
||||
ssh.key_path = "/explicit/key".to_string();
|
||||
let resolved = apply_host_entry(&ssh, entry("myserver"));
|
||||
assert_eq!(resolved.host, "10.0.0.5");
|
||||
assert_eq!(resolved.user, "alice");
|
||||
assert_eq!(resolved.port, 9999);
|
||||
assert_eq!(resolved.key_path, "/explicit/key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_is_noop_when_host_does_not_match_any_alias() {
|
||||
// `resolve_ssh_tunnel_config` looks up the real `~/.ssh/config`; an
|
||||
// alias this unlikely to exist on a test machine exercises the
|
||||
// "no match found" branch without needing to mock the filesystem.
|
||||
let ssh = config("dbx-test-alias-that-should-never-exist-anywhere");
|
||||
let resolved = resolve_ssh_tunnel_config(&ssh);
|
||||
assert_eq!(resolved, ssh);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,14 @@ pub struct ObjectInfo {
|
||||
pub parent_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExtensionInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub comment: Option<String>,
|
||||
pub schema: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObjectStatistics {
|
||||
pub name: String,
|
||||
|
||||
Reference in new issue
Block a user