feat(connection): add reusable tunnel profiles
This commit is contained in:
1 parent
4dcee1aca0
commit
977e7f78ca
34 files changed
+1335
-22
No files matched your search
@@ -91,6 +91,10 @@ pub struct SyncSnapshot {
|
||||
pub exported_at: String,
|
||||
pub app_version: String,
|
||||
pub connections: Vec<ConnectionConfig>,
|
||||
/// Shared tunnel profiles (secrets scrubbed). `None` means the snapshot
|
||||
/// predates tunnel profiles — applying it leaves local profiles alone.
|
||||
#[serde(default)]
|
||||
pub tunnel_profiles: Option<Vec<TransportLayerConfig>>,
|
||||
pub sidebar_layout: Option<serde_json::Value>,
|
||||
pub pinned_tree_node_ids: Vec<String>,
|
||||
pub saved_sql: SavedSqlLibrary,
|
||||
@@ -115,6 +119,9 @@ pub struct EncryptedSecretsBlob {
|
||||
pub struct SensitiveSyncPayload {
|
||||
pub connection_secrets: Vec<ConnectionSecretSnapshot>,
|
||||
pub ai_config: Option<AiConfig>,
|
||||
/// Full tunnel profiles including their secrets.
|
||||
#[serde(default)]
|
||||
pub tunnel_profiles: Option<Vec<TransportLayerConfig>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -163,21 +170,27 @@ pub async fn build_sync_snapshot(
|
||||
secrets_passphrase: Option<&str>,
|
||||
) -> Result<SyncSnapshot, String> {
|
||||
let mut connections = storage.load_connections().await?;
|
||||
let mut tunnel_profiles = storage.load_tunnel_profiles().await?;
|
||||
let encrypted_secrets = match normalized_passphrase(secrets_passphrase) {
|
||||
Some(passphrase) => {
|
||||
Some(encrypt_sensitive_payload(&build_sensitive_payload(storage, &connections).await?, passphrase)?)
|
||||
}
|
||||
Some(passphrase) => Some(encrypt_sensitive_payload(
|
||||
&build_sensitive_payload(storage, &connections, &tunnel_profiles).await?,
|
||||
passphrase,
|
||||
)?),
|
||||
None => None,
|
||||
};
|
||||
for config in &mut connections {
|
||||
scrub_connection_secrets(config);
|
||||
}
|
||||
for profile in &mut tunnel_profiles {
|
||||
profile.scrub_secrets();
|
||||
}
|
||||
|
||||
Ok(SyncSnapshot {
|
||||
schema_version: SNAPSHOT_SCHEMA_VERSION,
|
||||
exported_at: Utc::now().to_rfc3339(),
|
||||
app_version: app_version.into(),
|
||||
connections,
|
||||
tunnel_profiles: Some(tunnel_profiles),
|
||||
sidebar_layout: storage.load_sidebar_layout().await?,
|
||||
pinned_tree_node_ids: storage.load_pinned_tree_node_ids().await?,
|
||||
saved_sql: storage.load_saved_sql_library().await?,
|
||||
@@ -223,6 +236,9 @@ pub async fn apply_sync_snapshot(
|
||||
}
|
||||
|
||||
storage.save_connection_metadata_preserving_secrets(&connections).await?;
|
||||
if let Some(profiles) = &snapshot.tunnel_profiles {
|
||||
storage.save_tunnel_profiles_preserving_secrets(profiles).await?;
|
||||
}
|
||||
if let Some(layout) = &snapshot.sidebar_layout {
|
||||
storage.save_sidebar_layout(layout).await?;
|
||||
}
|
||||
@@ -602,6 +618,7 @@ fn webdav_password_account(config: &WebDavConfig) -> String {
|
||||
async fn build_sensitive_payload(
|
||||
storage: &Storage,
|
||||
connections: &[ConnectionConfig],
|
||||
tunnel_profiles: &[TransportLayerConfig],
|
||||
) -> Result<SensitiveSyncPayload, String> {
|
||||
let mut connection_secrets = Vec::new();
|
||||
for config in connections {
|
||||
@@ -648,7 +665,11 @@ async fn build_sensitive_payload(
|
||||
push_nacos_external_config_secrets(&mut connection_secrets, config);
|
||||
}
|
||||
|
||||
Ok(SensitiveSyncPayload { connection_secrets, ai_config: storage.load_ai_config().await? })
|
||||
Ok(SensitiveSyncPayload {
|
||||
connection_secrets,
|
||||
ai_config: storage.load_ai_config().await?,
|
||||
tunnel_profiles: Some(tunnel_profiles.to_vec()),
|
||||
})
|
||||
}
|
||||
|
||||
fn push_mq_external_config_secrets(secrets: &mut Vec<ConnectionSecretSnapshot>, config: &ConnectionConfig) {
|
||||
@@ -769,6 +790,9 @@ async fn apply_sensitive_payload(storage: &Storage, payload: &SensitiveSyncPaylo
|
||||
if let Some(ai_config) = &payload.ai_config {
|
||||
storage.save_ai_config(ai_config).await?;
|
||||
}
|
||||
if let Some(profiles) = &payload.tunnel_profiles {
|
||||
storage.save_tunnel_profiles(profiles).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -995,15 +1019,16 @@ fn parent_collection_paths(remote_path: &str) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_sync_snapshot_with_saved_secrets, decrypt_sensitive_payload, encrypt_sensitive_payload,
|
||||
forget_webdav_sync_secrets_passphrase, normalized_remote_path, parent_collection_paths,
|
||||
resolve_webdav_sync_secrets_passphrase, save_webdav_sync_secrets_preference, scrub_connection_secrets,
|
||||
snippet_file_content, snippet_response_id, webdav_sync_secrets_status, ConnectionSecretSnapshot,
|
||||
apply_sync_snapshot, build_sync_snapshot, build_sync_snapshot_with_saved_secrets, decrypt_sensitive_payload,
|
||||
encrypt_sensitive_payload, forget_webdav_sync_secrets_passphrase, normalized_remote_path,
|
||||
parent_collection_paths, resolve_webdav_sync_secrets_passphrase, save_webdav_sync_secrets_preference,
|
||||
scrub_connection_secrets, snippet_file_content, snippet_response_id, webdav_sync_secrets_status,
|
||||
ApplySnapshotOptions, ConnectionSecretSnapshot,
|
||||
SensitiveSyncPayload,
|
||||
};
|
||||
use crate::connection_secrets::NACOS_AUTH_PASSWORD_KEY;
|
||||
use crate::models::connection::{
|
||||
default_redis_key_separator, ConnectionConfig, DatabaseType, TransportLayerConfig,
|
||||
default_redis_key_separator, ConnectionConfig, DatabaseType, SshTunnelConfig, TransportLayerConfig,
|
||||
};
|
||||
use crate::storage::Storage;
|
||||
|
||||
@@ -1172,6 +1197,7 @@ mod tests {
|
||||
color: None,
|
||||
transport_layers: vec![
|
||||
TransportLayerConfig::Ssh(crate::models::connection::SshTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
id: "hop-1".to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
@@ -1188,6 +1214,7 @@ mod tests {
|
||||
auth_method: "password".to_string(),
|
||||
}),
|
||||
TransportLayerConfig::HttpTunnel(crate::models::connection::HttpTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
id: "http".to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
@@ -1247,6 +1274,7 @@ mod tests {
|
||||
#[test]
|
||||
fn encrypted_sensitive_payload_round_trips() {
|
||||
let payload = SensitiveSyncPayload {
|
||||
tunnel_profiles: None,
|
||||
connection_secrets: vec![
|
||||
ConnectionSecretSnapshot {
|
||||
connection_id: "c1".to_string(),
|
||||
@@ -1271,6 +1299,7 @@ mod tests {
|
||||
#[test]
|
||||
fn encrypted_sensitive_payload_rejects_wrong_passphrase() {
|
||||
let payload = SensitiveSyncPayload {
|
||||
tunnel_profiles: None,
|
||||
connection_secrets: vec![ConnectionSecretSnapshot {
|
||||
connection_id: "c1".to_string(),
|
||||
key: "password".to_string(),
|
||||
@@ -1389,4 +1418,41 @@ mod tests {
|
||||
secret.connection_id == "nacos" && secret.key == NACOS_AUTH_PASSWORD_KEY && secret.secret == "nacos-secret"
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_snapshot_round_trips_tunnel_profiles() {
|
||||
let storage = Storage::open(&temp_db_path("tunnel-profiles-src")).await.unwrap();
|
||||
let profile = TransportLayerConfig::Ssh(SshTunnelConfig {
|
||||
id: "profile-1".to_string(),
|
||||
name: "Bastion".to_string(),
|
||||
enabled: true,
|
||||
host: "bastion.example.com".to_string(),
|
||||
port: 22,
|
||||
user: "deploy".to_string(),
|
||||
password: "tunnel-secret".to_string(),
|
||||
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(),
|
||||
profile_id: String::new(),
|
||||
});
|
||||
storage.save_tunnel_profiles(std::slice::from_ref(&profile)).await.unwrap();
|
||||
|
||||
let snapshot = build_sync_snapshot(&storage, "test-version", None, Some("sync-pass")).await.unwrap();
|
||||
|
||||
// The plain snapshot carries the profiles with secrets scrubbed.
|
||||
let public_profiles = snapshot.tunnel_profiles.as_ref().expect("tunnel profiles in snapshot");
|
||||
let public_json = serde_json::to_string(public_profiles).unwrap();
|
||||
assert!(!public_json.contains("tunnel-secret"));
|
||||
|
||||
// Applying with the passphrase restores the full profile on the target.
|
||||
let target = Storage::open(&temp_db_path("tunnel-profiles-dst")).await.unwrap();
|
||||
apply_sync_snapshot(&target, &snapshot, ApplySnapshotOptions { secrets_passphrase: Some("sync-pass") })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(target.load_tunnel_profiles().await.unwrap(), vec![profile]);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ use crate::db::proxy_tunnel::ProxyTunnelManager;
|
||||
use crate::db::ssh_tunnel::TunnelManager;
|
||||
use crate::models::connection::{
|
||||
parse_jdbc_host_port, parse_mongo_first_host, rewrite_jdbc_url_host, ConnectionConfig, DatabaseType,
|
||||
TransportLayerConfig,
|
||||
};
|
||||
use crate::path_utils::expand_tilde;
|
||||
use crate::plugins::{PluginDriverSession, PluginRegistry, PluginRuntimeEnv};
|
||||
@@ -1376,12 +1377,61 @@ impl AppState {
|
||||
Ok(pool_key)
|
||||
}
|
||||
|
||||
/// Returns the enabled transport layers for a connection with tunnel
|
||||
/// profile references resolved: a layer carrying a `profile_id` is
|
||||
/// replaced by the shared profile from storage (Settings > Tunnels), so
|
||||
/// edits to a profile take effect for every connection referencing it.
|
||||
/// Fails when a referenced profile no longer exists — connecting without
|
||||
/// the intended tunnel would silently bypass it.
|
||||
pub async fn resolved_transport_layers(
|
||||
&self,
|
||||
config: &ConnectionConfig,
|
||||
) -> Result<Vec<TransportLayerConfig>, String> {
|
||||
let layers = config.effective_transport_layers();
|
||||
if layers.iter().all(|layer| layer.profile_id().is_empty()) {
|
||||
return Ok(layers);
|
||||
}
|
||||
|
||||
let profiles: HashMap<String, TransportLayerConfig> = self
|
||||
.storage
|
||||
.load_tunnel_profiles()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|profile| (profile.id().to_string(), profile))
|
||||
.collect();
|
||||
|
||||
layers
|
||||
.into_iter()
|
||||
.map(|layer| {
|
||||
let profile_id = layer.profile_id();
|
||||
if profile_id.is_empty() {
|
||||
return Ok(layer);
|
||||
}
|
||||
let Some(profile) = profiles.get(profile_id) else {
|
||||
let label = if layer.name().is_empty() { profile_id } else { layer.name() };
|
||||
return Err(format!(
|
||||
"Tunnel profile '{label}' referenced by this connection no longer exists. Re-create it in Settings > Tunnels or edit the connection's tunnel settings."
|
||||
));
|
||||
};
|
||||
// Validate the stored reference again at connect time because synced or
|
||||
// externally supplied configs may bypass the editor's type constraints.
|
||||
if !layer.same_type_as(profile) {
|
||||
return Err(format!(
|
||||
"Tunnel profile '{}' has a different type than the referencing transport layer.",
|
||||
if layer.name().is_empty() { profile_id } else { layer.name() }
|
||||
));
|
||||
}
|
||||
Ok(layer.resolved_from_profile(profile))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn connection_host_port(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
config: &ConnectionConfig,
|
||||
) -> Result<(String, u16), String> {
|
||||
let transport_layers = config.effective_transport_layers();
|
||||
let transport_layers = self.resolved_transport_layers(config).await?;
|
||||
if transport_layers.is_empty() {
|
||||
return Ok((config.host.clone(), config.port));
|
||||
}
|
||||
@@ -1406,7 +1456,7 @@ impl AppState {
|
||||
connection_id: &str,
|
||||
config: &ConnectionConfig,
|
||||
) -> Result<redis::aio::MultiplexedConnection, String> {
|
||||
let transport_layers = config.effective_transport_layers();
|
||||
let transport_layers = self.resolved_transport_layers(config).await?;
|
||||
if transport_layers.is_empty() {
|
||||
return db::redis_driver::connect_sentinel(config).await;
|
||||
}
|
||||
@@ -1536,7 +1586,7 @@ impl AppState {
|
||||
connection_id: &str,
|
||||
config: &ConnectionConfig,
|
||||
) -> Result<db::redis_driver::RedisClusterPool, String> {
|
||||
let transport_layers = config.effective_transport_layers();
|
||||
let transport_layers = self.resolved_transport_layers(config).await?;
|
||||
if transport_layers.is_empty() {
|
||||
return db::redis_driver::connect_cluster(config).await;
|
||||
}
|
||||
@@ -3098,8 +3148,8 @@ mod tests {
|
||||
use crate::database_capabilities;
|
||||
use crate::db;
|
||||
use crate::models::connection::{
|
||||
default_connect_timeout_secs, default_redis_key_separator, ConnectionConfig, DatabaseType, ProxyTunnelConfig,
|
||||
ProxyType, TransportLayerConfig,
|
||||
default_connect_timeout_secs, default_redis_key_separator, ConnectionConfig, DatabaseType, HttpTunnelConfig,
|
||||
ProxyTunnelConfig, ProxyType, SshTunnelConfig, TransportLayerConfig,
|
||||
};
|
||||
use crate::query;
|
||||
use crate::schema;
|
||||
@@ -4352,11 +4402,141 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
fn ssh_layer(id: &str, profile_id: &str) -> SshTunnelConfig {
|
||||
SshTunnelConfig {
|
||||
id: id.to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
host: String::new(),
|
||||
port: 22,
|
||||
user: String::new(),
|
||||
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: String::new(),
|
||||
profile_id: profile_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_layer(id: &str, profile_id: &str) -> ProxyTunnelConfig {
|
||||
ProxyTunnelConfig {
|
||||
id: id.to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
proxy_type: ProxyType::Socks5,
|
||||
host: String::new(),
|
||||
port: 1080,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
profile_id: profile_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_tunnel_layer(id: &str, profile_id: &str) -> HttpTunnelConfig {
|
||||
HttpTunnelConfig {
|
||||
id: id.to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
url: String::new(),
|
||||
token: String::new(),
|
||||
connect_timeout_secs: 10,
|
||||
profile_id: profile_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolved_transport_layers_substitutes_shared_profiles() {
|
||||
let (state, dir) = test_app_state().await;
|
||||
|
||||
let mut profile = ssh_layer("shared-bastion", "");
|
||||
profile.name = "Bastion".to_string();
|
||||
profile.host = "bastion.example.com".to_string();
|
||||
profile.user = "deploy".to_string();
|
||||
profile.password = "s3cret".to_string();
|
||||
profile.auth_method = "password".to_string();
|
||||
state.storage.save_tunnel_profiles(&[TransportLayerConfig::Ssh(profile)]).await.unwrap();
|
||||
|
||||
let mut config = mysql_config(Some("app"));
|
||||
config.transport_layers = vec![TransportLayerConfig::Ssh(ssh_layer("layer-1", "shared-bastion"))];
|
||||
|
||||
let resolved = state.resolved_transport_layers(&config).await.unwrap();
|
||||
assert_eq!(resolved.len(), 1);
|
||||
match &resolved[0] {
|
||||
TransportLayerConfig::Ssh(ssh) => {
|
||||
// Profile supplies the configuration; the layer keeps its identity.
|
||||
assert_eq!(ssh.id, "layer-1");
|
||||
assert_eq!(ssh.profile_id, "shared-bastion");
|
||||
assert_eq!(ssh.host, "bastion.example.com");
|
||||
assert_eq!(ssh.user, "deploy");
|
||||
assert_eq!(ssh.password, "s3cret");
|
||||
}
|
||||
other => panic!("expected ssh layer, got {other:?}"),
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolved_transport_layers_fails_closed_on_missing_profile() {
|
||||
let (state, dir) = test_app_state().await;
|
||||
|
||||
let mut config = mysql_config(Some("app"));
|
||||
config.transport_layers = vec![TransportLayerConfig::Ssh(ssh_layer("layer-1", "deleted-profile"))];
|
||||
|
||||
let err = state.resolved_transport_layers(&config).await.unwrap_err();
|
||||
assert!(err.contains("no longer exists"), "unexpected error: {err}");
|
||||
|
||||
// Disabled reference layers are filtered out before resolution, so a
|
||||
// dangling reference on a disabled layer must not block connecting.
|
||||
let mut disabled = ssh_layer("layer-1", "deleted-profile");
|
||||
disabled.enabled = false;
|
||||
config.transport_layers = vec![TransportLayerConfig::Ssh(disabled)];
|
||||
assert!(state.resolved_transport_layers(&config).await.unwrap().is_empty());
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolved_transport_layers_rejects_mismatched_profile_types() {
|
||||
let (state, dir) = test_app_state().await;
|
||||
|
||||
let mismatches = [
|
||||
(
|
||||
TransportLayerConfig::Ssh(ssh_layer("layer", "shared")),
|
||||
TransportLayerConfig::Proxy(proxy_layer("shared", "")),
|
||||
),
|
||||
(
|
||||
TransportLayerConfig::Proxy(proxy_layer("layer", "shared")),
|
||||
TransportLayerConfig::HttpTunnel(http_tunnel_layer("shared", "")),
|
||||
),
|
||||
(
|
||||
TransportLayerConfig::HttpTunnel(http_tunnel_layer("layer", "shared")),
|
||||
TransportLayerConfig::Ssh(ssh_layer("shared", "")),
|
||||
),
|
||||
];
|
||||
|
||||
for (layer, profile) in mismatches {
|
||||
state.storage.save_tunnel_profiles(&[profile]).await.unwrap();
|
||||
let mut config = mysql_config(Some("app"));
|
||||
config.transport_layers = vec![layer];
|
||||
|
||||
let error = state.resolved_transport_layers(&config).await.unwrap_err();
|
||||
assert!(error.contains("different type"));
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_connection_uses_local_forward_endpoint() {
|
||||
let (state, dir) = test_app_state().await;
|
||||
let mut config = mysql_config(Some("app"));
|
||||
config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
id: "proxy".to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
@@ -4405,6 +4585,7 @@ mod tests {
|
||||
"auth": { "kind": "none" }
|
||||
}));
|
||||
config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
id: "proxy".to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
@@ -4462,6 +4643,7 @@ mod tests {
|
||||
"auth": { "kind": "none" }
|
||||
}));
|
||||
config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
id: "proxy".to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
|
||||
@@ -770,6 +770,7 @@ mod tests {
|
||||
|
||||
fn ssh_hop(id: &str, password: &str, passphrase: &str) -> SshTunnelConfig {
|
||||
SshTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
id: id.to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
@@ -789,6 +790,7 @@ mod tests {
|
||||
|
||||
fn http_tunnel(id: &str, token: &str) -> TransportLayerConfig {
|
||||
TransportLayerConfig::HttpTunnel(HttpTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
id: id.to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
|
||||
@@ -870,6 +870,7 @@ mod tests {
|
||||
|
||||
fn hop(id: &str, host: &str, port: u16) -> SshTunnelConfig {
|
||||
SshTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
id: id.to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
|
||||
@@ -247,6 +247,7 @@ mod tests {
|
||||
|
||||
fn ssh_layer(id: &str, host: &str, port: u16) -> TransportLayerConfig {
|
||||
TransportLayerConfig::Ssh(SshTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
id: id.to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
@@ -266,6 +267,7 @@ mod tests {
|
||||
|
||||
fn proxy_layer(id: &str, host: &str, port: u16) -> TransportLayerConfig {
|
||||
TransportLayerConfig::Proxy(ProxyTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
id: id.to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
@@ -279,6 +281,7 @@ mod tests {
|
||||
|
||||
fn http_tunnel_layer(id: &str, url: &str) -> TransportLayerConfig {
|
||||
TransportLayerConfig::HttpTunnel(HttpTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
id: id.to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
|
||||
@@ -108,6 +108,15 @@ pub enum TransportLayerConfig {
|
||||
}
|
||||
|
||||
impl TransportLayerConfig {
|
||||
pub fn same_type_as(&self, other: &TransportLayerConfig) -> bool {
|
||||
matches!(
|
||||
(self, other),
|
||||
(TransportLayerConfig::Ssh(_), TransportLayerConfig::Ssh(_))
|
||||
| (TransportLayerConfig::Proxy(_), TransportLayerConfig::Proxy(_))
|
||||
| (TransportLayerConfig::HttpTunnel(_), TransportLayerConfig::HttpTunnel(_))
|
||||
)
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
TransportLayerConfig::Ssh(layer) => &layer.id,
|
||||
@@ -116,6 +125,56 @@ impl TransportLayerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn profile_id(&self) -> &str {
|
||||
match self {
|
||||
TransportLayerConfig::Ssh(layer) => &layer.profile_id,
|
||||
TransportLayerConfig::Proxy(layer) => &layer.profile_id,
|
||||
TransportLayerConfig::HttpTunnel(layer) => &layer.profile_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the concrete layer used at connect time for a layer that
|
||||
/// references a shared tunnel profile: the profile supplies the whole
|
||||
/// configuration while the referencing layer keeps its own identity,
|
||||
/// enabled flag, and profile reference.
|
||||
pub fn resolved_from_profile(&self, profile: &TransportLayerConfig) -> TransportLayerConfig {
|
||||
let mut resolved = profile.clone();
|
||||
let (id, enabled, profile_id) = (self.id().to_string(), self.enabled(), self.profile_id().to_string());
|
||||
match &mut resolved {
|
||||
TransportLayerConfig::Ssh(layer) => {
|
||||
layer.id = id;
|
||||
layer.enabled = enabled;
|
||||
layer.profile_id = profile_id;
|
||||
}
|
||||
TransportLayerConfig::Proxy(layer) => {
|
||||
layer.id = id;
|
||||
layer.enabled = enabled;
|
||||
layer.profile_id = profile_id;
|
||||
}
|
||||
TransportLayerConfig::HttpTunnel(layer) => {
|
||||
layer.id = id;
|
||||
layer.enabled = enabled;
|
||||
layer.profile_id = profile_id;
|
||||
}
|
||||
}
|
||||
resolved
|
||||
}
|
||||
|
||||
pub fn scrub_secrets(&mut self) {
|
||||
match self {
|
||||
TransportLayerConfig::Ssh(layer) => {
|
||||
layer.password = String::new();
|
||||
layer.key_passphrase = String::new();
|
||||
}
|
||||
TransportLayerConfig::Proxy(layer) => {
|
||||
layer.password = String::new();
|
||||
}
|
||||
TransportLayerConfig::HttpTunnel(layer) => {
|
||||
layer.token = String::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
TransportLayerConfig::Ssh(layer) => &layer.name,
|
||||
@@ -181,6 +240,12 @@ pub struct SshTunnelConfig {
|
||||
/// only tries that method (after the standard `none` probe).
|
||||
#[serde(default)]
|
||||
pub auth_method: String,
|
||||
/// When non-empty, this layer references a shared tunnel profile
|
||||
/// (Settings > Tunnels). The profile's configuration replaces this
|
||||
/// layer's own fields at connect time; only `id` and `enabled` are
|
||||
/// kept from the referencing layer.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub profile_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -201,6 +266,9 @@ pub struct ProxyTunnelConfig {
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
/// See [`SshTunnelConfig::profile_id`].
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub profile_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -217,6 +285,9 @@ pub struct HttpTunnelConfig {
|
||||
pub token: String,
|
||||
#[serde(default = "default_http_tunnel_connect_timeout_secs")]
|
||||
pub connect_timeout_secs: u64,
|
||||
/// See [`SshTunnelConfig::profile_id`].
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub profile_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -2028,6 +2099,7 @@ mod tests {
|
||||
fn serialized_connection_config_omits_legacy_transport_fields() {
|
||||
let mut config = mysql_config("root", "", None);
|
||||
config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
id: "proxy".to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
|
||||
@@ -179,6 +179,7 @@ mod tests {
|
||||
|
||||
fn config(host: &str) -> SshTunnelConfig {
|
||||
SshTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
id: "1".to_string(),
|
||||
name: String::new(),
|
||||
enabled: true,
|
||||
|
||||
@@ -224,6 +224,10 @@ const SCHEMA_STATEMENTS: &[&str] = &[
|
||||
provider TEXT PRIMARY KEY,
|
||||
config_json TEXT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS tunnel_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
config_json TEXT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS ai_conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
@@ -761,6 +765,104 @@ impl Storage {
|
||||
}
|
||||
}
|
||||
|
||||
// Tunnel profiles — shared transport-layer configurations managed in
|
||||
// Settings and referenced from connections via `profile_id`. Secrets stay
|
||||
// inline in `config_json`; that matches the plaintext-at-rest posture of
|
||||
// `connection_secrets` in the same database file.
|
||||
|
||||
impl Storage {
|
||||
pub async fn load_tunnel_profiles(&self) -> Result<Vec<TransportLayerConfig>, String> {
|
||||
let rows: Vec<String> = self
|
||||
.with_conn(|conn| {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT config_json FROM tunnel_profiles ORDER BY rowid")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut profiles = Vec::new();
|
||||
for json in rows {
|
||||
match serde_json::from_str::<TransportLayerConfig>(&json) {
|
||||
Ok(profile) => profiles.push(profile),
|
||||
Err(e) => warn!("Failed to deserialize tunnel profile: {}", e),
|
||||
}
|
||||
}
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
pub async fn save_tunnel_profiles(&self, profiles: &[TransportLayerConfig]) -> Result<(), String> {
|
||||
for profile in profiles {
|
||||
if profile.id().trim().is_empty() {
|
||||
return Err("Tunnel profile id must not be empty".to_string());
|
||||
}
|
||||
}
|
||||
let profiles = profiles.to_vec();
|
||||
self.with_conn(move |conn| {
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM tunnel_profiles", []).map_err(|e| e.to_string())?;
|
||||
for profile in &profiles {
|
||||
let json = serde_json::to_string(profile).map_err(|e| e.to_string())?;
|
||||
tx.execute(
|
||||
"INSERT INTO tunnel_profiles (id, config_json) VALUES (?1, ?2)",
|
||||
params![profile.id(), json],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
tx.commit().map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Replaces the profile catalog while keeping the secrets already stored
|
||||
/// for a profile when the incoming copy has them scrubbed. Used when
|
||||
/// applying sync snapshots, whose plain (non-encrypted) part strips
|
||||
/// tunnel secrets.
|
||||
pub async fn save_tunnel_profiles_preserving_secrets(
|
||||
&self,
|
||||
profiles: &[TransportLayerConfig],
|
||||
) -> Result<(), String> {
|
||||
let existing: HashMap<String, TransportLayerConfig> =
|
||||
self.load_tunnel_profiles().await?.into_iter().map(|p| (p.id().to_string(), p)).collect();
|
||||
let merged: Vec<TransportLayerConfig> = profiles
|
||||
.iter()
|
||||
.map(|profile| {
|
||||
let mut profile = profile.clone();
|
||||
if let Some(previous) = existing.get(profile.id()) {
|
||||
merge_missing_tunnel_profile_secrets(&mut profile, previous);
|
||||
}
|
||||
profile
|
||||
})
|
||||
.collect();
|
||||
self.save_tunnel_profiles(&merged).await
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_missing_tunnel_profile_secrets(profile: &mut TransportLayerConfig, previous: &TransportLayerConfig) {
|
||||
match (profile, previous) {
|
||||
(TransportLayerConfig::Ssh(current), TransportLayerConfig::Ssh(previous)) => {
|
||||
if current.password.is_empty() {
|
||||
current.password = previous.password.clone();
|
||||
}
|
||||
if current.key_passphrase.is_empty() {
|
||||
current.key_passphrase = previous.key_passphrase.clone();
|
||||
}
|
||||
}
|
||||
(TransportLayerConfig::Proxy(current), TransportLayerConfig::Proxy(previous)) => {
|
||||
if current.password.is_empty() {
|
||||
current.password = previous.password.clone();
|
||||
}
|
||||
}
|
||||
(TransportLayerConfig::HttpTunnel(current), TransportLayerConfig::HttpTunnel(previous)) => {
|
||||
if current.token.is_empty() {
|
||||
current.token = previous.token.clone();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// App Settings
|
||||
|
||||
impl Storage {
|
||||
@@ -2393,7 +2495,7 @@ mod tests {
|
||||
use crate::connection_secrets::{
|
||||
MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY,
|
||||
};
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType, SshTunnelConfig, TransportLayerConfig};
|
||||
use crate::saved_sql::SavedSqlFile;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -2407,6 +2509,65 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("dbx-storage-{name}-{}-{stamp}", std::process::id()))
|
||||
}
|
||||
|
||||
fn ssh_profile(id: &str, password: &str) -> TransportLayerConfig {
|
||||
TransportLayerConfig::Ssh(SshTunnelConfig {
|
||||
id: id.to_string(),
|
||||
name: "Bastion".to_string(),
|
||||
enabled: true,
|
||||
host: "bastion.example.com".to_string(),
|
||||
port: 22,
|
||||
user: "deploy".to_string(),
|
||||
password: password.to_string(),
|
||||
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(),
|
||||
profile_id: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tunnel_profiles_roundtrip_and_preserve_secrets() {
|
||||
let path = temp_db_path("tunnel-profiles");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
|
||||
let profile = ssh_profile("profile-1", "s3cret");
|
||||
storage.save_tunnel_profiles(std::slice::from_ref(&profile)).await.unwrap();
|
||||
assert_eq!(storage.load_tunnel_profiles().await.unwrap(), vec![profile.clone()]);
|
||||
|
||||
// Applying a scrubbed copy (e.g. from a sync snapshot) keeps stored secrets.
|
||||
let mut scrubbed = profile.clone();
|
||||
scrubbed.scrub_secrets();
|
||||
storage.save_tunnel_profiles_preserving_secrets(&[scrubbed.clone()]).await.unwrap();
|
||||
match &storage.load_tunnel_profiles().await.unwrap()[0] {
|
||||
TransportLayerConfig::Ssh(ssh) => assert_eq!(ssh.password, "s3cret"),
|
||||
other => panic!("expected ssh profile, got {other:?}"),
|
||||
}
|
||||
|
||||
// A plain save is exact: clearing a secret really clears it.
|
||||
storage.save_tunnel_profiles(&[scrubbed.clone()]).await.unwrap();
|
||||
assert_eq!(storage.load_tunnel_profiles().await.unwrap(), vec![scrubbed]);
|
||||
|
||||
storage.save_tunnel_profiles(&[]).await.unwrap();
|
||||
assert!(storage.load_tunnel_profiles().await.unwrap().is_empty());
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tunnel_profiles_reject_empty_ids() {
|
||||
let path = temp_db_path("tunnel-profiles-empty-id");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
|
||||
let profile = ssh_profile("", "secret");
|
||||
assert!(storage.save_tunnel_profiles(&[profile]).await.is_err());
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
fn mq_connection(id: &str, token: &str) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
id: id.to_string(),
|
||||
|
||||
Reference in new issue
Block a user