feat(connection): add reusable tunnel profiles

This commit is contained in:
rainhan authored and GitHub committed 2026-07-12 22:03:46 +08:00
1 parent 4dcee1aca0
commit 977e7f78ca
34 files changed
+1335 -22

No files matched your search

+75 -9
View File
@@ -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]);
}
}
+187 -5
View File
@@ -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,
+1
View File
@@ -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,
+72
View File
@@ -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,
+1
View File
@@ -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,
+162 -1
View File
@@ -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(),