diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 60d7edeb6..8b4f415b6 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; -import type { ConnectionConfig, DatabaseType, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshTunnelConfig, TransportLayerConfig } from "@/types/database"; +import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshTunnelConfig, TransportLayerConfig } from "@/types/database"; import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq"; import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -234,6 +234,17 @@ function defaultProxyTunnel(): ProxyTunnelConfig { }; } +function defaultHttpTunnel(): HttpTunnelConfig { + return { + id: uuid(), + name: "", + enabled: true, + url: "", + token: "", + connect_timeout_secs: 10, + }; +} + function normalizeProxyTunnel(layer: Partial): ProxyTunnelConfig { return { id: layer.id || uuid(), @@ -247,10 +258,24 @@ function normalizeProxyTunnel(layer: Partial): ProxyTunnelCon }; } +function normalizeHttpTunnel(layer: Partial): HttpTunnelConfig { + return { + id: layer.id || uuid(), + name: layer.name || "", + enabled: layer.enabled !== false, + url: layer.url || "", + token: layer.token || "", + connect_timeout_secs: Number(layer.connect_timeout_secs) || 10, + }; +} + function normalizeTransportLayer(layer: Partial): TransportLayerConfig { if (layer.type === "proxy") { return { type: "proxy", ...normalizeProxyTunnel(layer) }; } + if (layer.type === "http_tunnel") { + return { type: "http_tunnel", ...normalizeHttpTunnel(layer) }; + } return { type: "ssh", ...normalizeSshTunnel(layer as Partial) }; } @@ -1334,16 +1359,22 @@ const selectedTransportLayer = computed(() => { }); const selectedSshLayer = computed(() => (selectedTransportLayer.value?.type === "ssh" ? selectedTransportLayer.value : null)); const selectedProxyLayer = computed(() => (selectedTransportLayer.value?.type === "proxy" ? selectedTransportLayer.value : null)); +const selectedHttpTunnelLayer = computed(() => (selectedTransportLayer.value?.type === "http_tunnel" ? selectedTransportLayer.value : null)); + +function transportLayerDefaultName(layer: TransportLayerConfig, index: number): string { + if (layer.type === "proxy") return `Proxy ${index + 1}`; + if (layer.type === "http_tunnel") return t("connection.httpTunnelDefaultName", { index: index + 1 }); + return t("connection.sshHopDefaultName", { index: index + 1 }); +} + +function transportLayerDisplayName(layer: TransportLayerConfig, index: number): string { + const target = layer.type === "http_tunnel" ? layer.url?.trim() : layer.host?.trim(); + return layer.name?.trim() || target || transportLayerDefaultName(layer, index); +} + const transportPathSegments = computed(() => { const layers = transportLayers.value.filter((layer) => layer.enabled !== false); - return [ - "DBX", - ...layers.map((layer, index) => { - const fallback = layer.type === "proxy" ? `Proxy ${index + 1}` : `SSH ${index + 1}`; - return layer.name?.trim() || layer.host?.trim() || fallback; - }), - form.value.host || "Database", - ]; + return ["DBX", ...layers.map(transportLayerDisplayName), form.value.host || "Database"]; }); function defaultDatabaseForProfile() { @@ -2838,6 +2869,14 @@ function addProxyTunnel() { resetTestState(); } +function addHttpTunnel() { + const next: TransportLayerConfig = { type: "http_tunnel", ...defaultHttpTunnel() }; + next.name = t("connection.httpTunnelDefaultName", { index: 1 }); + form.value.transport_layers = [next, ...transportLayers.value]; + selectedTransportLayerId.value = next.id; + resetTestState(); +} + function duplicateTransportLayer(layer: TransportLayerConfig) { const next = normalizeTransportLayer({ ...layer, id: uuid(), name: layer.name ? `${layer.name} copy` : "" }); form.value.transport_layers = [...transportLayers.value, next]; @@ -2875,10 +2914,11 @@ function dropTransportLayer(targetId: string) { resetTestState(); } -function changeSelectedTransportLayerType(type: "ssh" | "proxy") { +function changeSelectedTransportLayerType(type: "ssh" | "proxy" | "http_tunnel") { const selected = selectedTransportLayer.value; if (!selected || selected.type === type) return; - const replacement: TransportLayerConfig = type === "proxy" ? { type: "proxy", ...defaultProxyTunnel(), id: selected.id, name: selected.name } : { type: "ssh", ...defaultSshTunnel(), id: selected.id, name: selected.name }; + const replacement: TransportLayerConfig = + type === "proxy" ? { type: "proxy", ...defaultProxyTunnel(), id: selected.id, name: selected.name } : type === "http_tunnel" ? { type: "http_tunnel", ...defaultHttpTunnel(), id: selected.id, name: selected.name } : { type: "ssh", ...defaultSshTunnel(), id: selected.id, name: selected.name }; form.value.transport_layers = transportLayers.value.map((layer) => (layer.id === selected.id ? replacement : layer)); resetTestState(); } @@ -2894,7 +2934,16 @@ function validateTransportLayers(config: LegacyConnectionConfig) { const layers = config.transport_layers || []; layers.forEach((layer, index) => { if (layer.enabled === false) return; - const label = layer.name?.trim() || t("connection.sshHopDefaultName", { index: index + 1 }); + const label = layer.name?.trim() || transportLayerDefaultName(layer, index); + if (layer.type === "http_tunnel") { + if (index !== 0) throw new Error(t("connection.httpTunnelInvalidOrder", { hop: label })); + if (!layer.url?.trim()) throw new Error(t("connection.httpTunnelInvalidUrl", { hop: label })); + const timeout = Number(layer.connect_timeout_secs); + if (!Number.isFinite(timeout) || timeout < 1 || timeout > 300) { + throw new Error(t("connection.httpTunnelInvalidTimeout", { hop: label })); + } + return; + } if (!layer.host?.trim()) throw new Error(t("connection.sshHopInvalidHost", { hop: label })); const port = Number(layer.port); if (!Number.isFinite(port) || port < 1 || port > 65535) { @@ -4589,17 +4638,17 @@ function openExternalUrl(url: string) { -
-
+
+
-
-
+
+
-
+
-
+
+
@@ -4756,6 +4810,20 @@ function openExternalUrl(url: string) {
+
@@ -4960,8 +5028,4 @@ function openExternalUrl(url: string) { width: min(100%, 36rem); margin-inline: auto; } - -.connection-dialog-content .grid.grid-cols-4.connection-label-wide-grid { - grid-template-columns: 7.75rem repeat(3, minmax(0, 1fr)); -} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 0480cd629..af605d6a1 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -323,9 +323,9 @@ export default { duplicatedSelected: "{count} connections duplicated", groupDeleted: "Group deleted", loadFailed: "Failed to load saved connections: {message}", - sshTunnel: "SSH Tunnel / Proxy", + sshTunnel: "Tunnel / Proxy", advancedTab: "Advanced", - sshEnable: "Use SSH tunnel / proxy", + sshEnable: "Use tunnel / proxy", sshHost: "SSH Host", sshUser: "SSH User", sshPassword: "SSH Password", @@ -336,7 +336,7 @@ export default { sshKeyPathBrowse: "Browse", sshExposeLan: "Expose tunnel to LAN", sshConnectTimeout: "SSH Timeout (seconds)", - sshHops: "SSH Tunnel / Proxy Layers", + sshHops: "Tunnel / Proxy Layers", sshHopAdd: "Add SSH layer", sshHopDuplicate: "Duplicate", sshHopDelete: "Delete", @@ -368,6 +368,16 @@ export default { proxyUsernamePlaceholder: "Optional", proxyPassword: "Proxy Password", proxyPasswordPlaceholder: "Optional", + httpTunnel: "HTTP Tunnel", + httpTunnelAdd: "Add HTTP tunnel", + httpTunnelUrl: "Tunnel Script URL", + httpTunnelToken: "Tunnel Token", + httpTunnelTokenPlaceholder: "Token configured in dbx_tunnel.php", + httpTunnelConnectTimeout: "Tunnel Timeout (seconds)", + httpTunnelDefaultName: "HTTP Tunnel {index}", + httpTunnelInvalidOrder: "{hop}: HTTP tunnel must be the first tunnel / proxy layer", + httpTunnelInvalidUrl: "{hop}: tunnel script URL is required", + httpTunnelInvalidTimeout: "{hop}: HTTP tunnel timeout must be between 1 and 300 seconds", dremioArrowFlightSqlMode: "Arrow Flight SQL", dremioLegacyJdbcMode: "Legacy JDBC", jdbcUrl: "JDBC URL", @@ -2774,9 +2784,9 @@ export default { syncRememberWebDavPasswordDescription: "The password is encrypted and stored on this device only. It is not synced to WebDAV and does not replace the sync password.", syncRemotePath: "Remote snapshot path", syncRemotePathDescription: "DBX will create missing parent folders when uploading.", - syncSecretNotice: "By default, DBX syncs connection details and settings only. Database passwords, SSH passwords, proxy passwords, connection strings, and AI API keys stay local.", + syncSecretNotice: "By default, DBX syncs connection details and settings only. Database passwords, SSH passwords, proxy passwords, HTTP tunnel tokens, connection strings, and AI API keys stay local.", syncSecrets: "Sync encrypted secrets", - syncSecretsDescription: "When enabled, DBX encrypts database passwords, SSH passwords, and AI API keys before uploading them to WebDAV.", + syncSecretsDescription: "When enabled, DBX encrypts database passwords, SSH passwords, proxy passwords, HTTP tunnel tokens, and AI API keys before uploading them to WebDAV.", syncSecretsPassphrase: "Sync password", syncSecretsPassphraseDescription: "This password is only used to encrypt and restore sensitive data. DBX does not save it; enter it again on each device.", syncTest: "Test", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index e9d0e5de9..4bd271c6a 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -325,9 +325,9 @@ export default withEnglishFallback({ duplicatedSelected: "已复制 {count} 个连接", groupDeleted: "分组已删除", loadFailed: "加载已保存连接失败:{message}", - sshTunnel: "SSH 隧道/代理", + sshTunnel: "隧道/代理", advancedTab: "高级", - sshEnable: "使用 SSH 隧道/代理连接", + sshEnable: "使用隧道/代理连接", sshHost: "SSH 主机", sshUser: "SSH 用户", sshPassword: "SSH 密码", @@ -338,7 +338,7 @@ export default withEnglishFallback({ sshKeyPathBrowse: "浏览", sshExposeLan: "允许局域网访问隧道", sshConnectTimeout: "SSH 超时时间(秒)", - sshHops: "SSH 隧道/代理层级", + sshHops: "隧道/代理层级", sshHopAdd: "添加 SSH 层", sshHopDuplicate: "复制", sshHopDelete: "删除", @@ -370,6 +370,16 @@ export default withEnglishFallback({ proxyUsernamePlaceholder: "可选", proxyPassword: "代理密码", proxyPasswordPlaceholder: "可选", + httpTunnel: "HTTP 隧道", + httpTunnelAdd: "添加 HTTP 隧道", + httpTunnelUrl: "隧道脚本 URL", + httpTunnelToken: "隧道 Token", + httpTunnelTokenPlaceholder: "dbx_tunnel.php 中配置的 token", + httpTunnelConnectTimeout: "隧道超时(秒)", + httpTunnelDefaultName: "HTTP 隧道 {index}", + httpTunnelInvalidOrder: "{hop}:HTTP 隧道必须位于隧道/代理第一层", + httpTunnelInvalidUrl: "{hop}:隧道脚本 URL 不能为空", + httpTunnelInvalidTimeout: "{hop}:HTTP 隧道超时时间必须在 1 到 300 秒之间", dremioArrowFlightSqlMode: "Arrow Flight SQL", dremioLegacyJdbcMode: "Legacy JDBC", jdbcUrl: "JDBC URL", @@ -2774,9 +2784,9 @@ export default withEnglishFallback({ syncRememberWebDavPasswordDescription: "密码会加密保存在本机,不会同步到 WebDAV,也不会替代同步密码。", syncRemotePath: "远端快照路径", syncRemotePathDescription: "上传时 DBX 会自动创建缺失的父目录。", - syncSecretNotice: "默认只同步连接信息和设置,不会同步数据库密码、SSH 密码、代理密码、连接串或 AI API Key。", + syncSecretNotice: "默认只同步连接信息和设置,不会同步数据库密码、SSH 密码、代理密码、HTTP 隧道 Token、连接串或 AI API Key。", syncSecrets: "同步加密后的敏感信息", - syncSecretsDescription: "开启后,DBX 会先加密数据库密码、SSH 密码和 AI API Key,再上传到 WebDAV。", + syncSecretsDescription: "开启后,DBX 会先加密数据库密码、SSH 密码、代理密码、HTTP 隧道 Token 和 AI API Key,再上传到 WebDAV。", syncSecretsPassphrase: "同步密码", syncSecretsPassphraseDescription: "这个密码只用于加密和恢复敏感信息,DBX 不会保存;换设备恢复时需要再次输入。", syncTest: "测试", diff --git a/apps/desktop/src/lib/__tests__/connectionAttemptTimeout.spec.ts b/apps/desktop/src/lib/__tests__/connectionAttemptTimeout.spec.ts index 1a8bebe35..2cda2f5ee 100644 --- a/apps/desktop/src/lib/__tests__/connectionAttemptTimeout.spec.ts +++ b/apps/desktop/src/lib/__tests__/connectionAttemptTimeout.spec.ts @@ -34,6 +34,41 @@ describe("connectionAttemptTimeout", () => { expect(connectionAttemptTimeoutMs({ db_type: "access", connect_timeout_secs: 45, transport_layers: [] })).toBe(47_000); }); + it("includes HTTP tunnel connection timeout values", () => { + expect( + connectionAttemptTimeoutMs({ + db_type: "mysql", + connect_timeout_secs: 5, + transport_layers: [ + { + type: "http_tunnel", + id: "http", + url: "https://dbx.example.com/dbx_tunnel.php", + connect_timeout_secs: 25, + }, + ], + }), + ).toBe(27_000); + }); + + it("ignores disabled transport layer timeouts", () => { + expect( + connectionAttemptTimeoutMs({ + db_type: "mysql", + connect_timeout_secs: 5, + transport_layers: [ + { + type: "http_tunnel", + id: "http", + enabled: false, + url: "https://dbx.example.com/dbx_tunnel.php", + connect_timeout_secs: 60, + }, + ], + }), + ).toBe(7_000); + }); + it("adds late original database errors to timeout messages", () => { const timeoutMessage = connectionAttemptTimeoutMessage(7_000); diff --git a/apps/desktop/src/lib/connectionAttemptTimeout.ts b/apps/desktop/src/lib/connectionAttemptTimeout.ts index 23d914dbf..fc3ddfc6d 100644 --- a/apps/desktop/src/lib/connectionAttemptTimeout.ts +++ b/apps/desktop/src/lib/connectionAttemptTimeout.ts @@ -54,7 +54,8 @@ export function connectionAttemptTimeoutMs(config: Pick { proxy.password.clear(); } + TransportLayerConfig::HttpTunnel(http) => { + http.token.clear(); + } } } config.redis_sentinel_password.clear(); @@ -364,6 +367,14 @@ async fn build_sensitive_payload( &proxy.password, ); } + TransportLayerConfig::HttpTunnel(http) => { + push_secret( + &mut connection_secrets, + &config.id, + &transport_layer_http_tunnel_token_key(index, layer), + &http.token, + ); + } } } push_secret(&mut connection_secrets, &config.id, "redis_sentinel_password", &config.redis_sentinel_password); @@ -449,6 +460,9 @@ async fn clear_connection_secrets(storage: &Storage, connections: &[ConnectionCo TransportLayerConfig::Proxy(_) => { storage.delete_secret(&config.id, &transport_layer_proxy_password_key(index, layer)).await?; } + TransportLayerConfig::HttpTunnel(_) => { + storage.delete_secret(&config.id, &transport_layer_http_tunnel_token_key(index, layer)).await?; + } } } } @@ -476,6 +490,10 @@ fn transport_layer_proxy_password_key(index: usize, layer: &TransportLayerConfig format!("{}{}.proxy_password", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) } +fn transport_layer_http_tunnel_token_key(index: usize, layer: &TransportLayerConfig) -> String { + format!("{}{}.http_tunnel_token", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) +} + fn encrypt_sensitive_payload(payload: &SensitiveSyncPayload, passphrase: &str) -> Result { let plaintext = serde_json::to_vec(payload).map_err(|e| e.to_string())?; encrypt_bytes_with_secret(&plaintext, passphrase) @@ -605,21 +623,31 @@ mod tests { visible_schemas: None, attached_databases: Vec::new(), color: None, - transport_layers: vec![TransportLayerConfig::Ssh(crate::models::connection::SshTunnelConfig { - id: "hop-1".to_string(), - name: String::new(), - enabled: true, - host: "bastion".to_string(), - port: 22, - user: "user".to_string(), - password: "hop-password".to_string(), - key_path: String::new(), - key_passphrase: "hop-passphrase".to_string(), - connect_timeout_secs: 5, - expose_lan: false, - use_ssh_agent: false, - ssh_agent_sock_path: String::new(), - })], + transport_layers: vec![ + TransportLayerConfig::Ssh(crate::models::connection::SshTunnelConfig { + id: "hop-1".to_string(), + name: String::new(), + enabled: true, + host: "bastion".to_string(), + port: 22, + user: "user".to_string(), + password: "hop-password".to_string(), + key_path: String::new(), + key_passphrase: "hop-passphrase".to_string(), + connect_timeout_secs: 5, + expose_lan: false, + use_ssh_agent: false, + ssh_agent_sock_path: String::new(), + }), + TransportLayerConfig::HttpTunnel(crate::models::connection::HttpTunnelConfig { + id: "http".to_string(), + name: String::new(), + enabled: true, + url: "https://dbx.example.com/dbx_tunnel.php".to_string(), + token: "tunnel-token".to_string(), + connect_timeout_secs: 10, + }), + ], connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, @@ -656,7 +684,11 @@ mod tests { assert!(ssh.password.is_empty()); assert!(ssh.key_passphrase.is_empty()); } - TransportLayerConfig::Proxy(_) => panic!("expected ssh layer"), + _ => panic!("expected ssh layer"), + } + match &config.transport_layers[1] { + TransportLayerConfig::HttpTunnel(http) => assert!(http.token.is_empty()), + _ => panic!("expected http tunnel layer"), } assert!(config.redis_sentinel_password.is_empty()); assert!(config.connection_string.is_none()); diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 289ce0073..34e842f5b 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -17,6 +17,7 @@ use crate::agent_manager::{JavaRuntimeMode, DEFAULT_JRE_KEY}; use crate::database_capabilities; use crate::db; use crate::db::agent_driver::AgentMethod; +use crate::db::http_tunnel::HttpTunnelManager; use crate::db::proxy_tunnel::ProxyTunnelManager; use crate::db::ssh_tunnel::TunnelManager; use crate::models::connection::{ @@ -161,6 +162,7 @@ pub struct AppState { pub running_queries: RunningQueries, pub tunnels: TunnelManager, pub proxy_tunnels: ProxyTunnelManager, + pub http_tunnels: HttpTunnelManager, pub storage: Storage, pub plugins: PluginRegistry, pub agent_manager: crate::agent_manager::AgentManager, @@ -457,6 +459,7 @@ impl AppState { running_queries: RunningQueries::default(), tunnels: TunnelManager::new(), proxy_tunnels: ProxyTunnelManager::new(), + http_tunnels: HttpTunnelManager::new(), storage, plugins: PluginRegistry::new(plugin_dir), agent_manager: crate::agent_manager::AgentManager::new_with_base_dir_and_app_version( @@ -1123,6 +1126,7 @@ impl AppState { remote_port, &self.tunnels, &self.proxy_tunnels, + &self.http_tunnels, ) .await?; @@ -1154,6 +1158,7 @@ impl AppState { sentinel.port, &self.tunnels, &self.proxy_tunnels, + &self.http_tunnels, ) .await { @@ -1178,6 +1183,7 @@ impl AppState { layer_count, &self.tunnels, &self.proxy_tunnels, + &self.http_tunnels, ) .await; continue; @@ -1192,6 +1198,7 @@ impl AppState { master.port, &self.tunnels, &self.proxy_tunnels, + &self.http_tunnels, ) .await { @@ -1206,6 +1213,7 @@ impl AppState { layer_count, &self.tunnels, &self.proxy_tunnels, + &self.http_tunnels, ) .await; continue; @@ -1226,6 +1234,7 @@ impl AppState { layer_count, &self.tunnels, &self.proxy_tunnels, + &self.http_tunnels, ) .await; db::transport_layer_tunnel::stop_transport_layers( @@ -1233,6 +1242,7 @@ impl AppState { layer_count, &self.tunnels, &self.proxy_tunnels, + &self.http_tunnels, ) .await; } @@ -1247,6 +1257,7 @@ impl AppState { let redis_sentinel_prefix = redis_sentinel_transport_prefix(connection_id); self.tunnels.stop_tunnels_with_prefix(&redis_sentinel_prefix).await; self.proxy_tunnels.stop_tunnels_with_prefix(&redis_sentinel_prefix).await; + self.http_tunnels.stop_tunnels_with_prefix(&redis_sentinel_prefix).await; } result @@ -1278,6 +1289,7 @@ impl AppState { let redis_cluster_prefix = redis_cluster_transport_prefix(connection_id); self.tunnels.stop_tunnels_with_prefix(&redis_cluster_prefix).await; self.proxy_tunnels.stop_tunnels_with_prefix(&redis_cluster_prefix).await; + self.http_tunnels.stop_tunnels_with_prefix(&redis_cluster_prefix).await; } result @@ -1299,6 +1311,7 @@ impl AppState { node.port, &self.tunnels, &self.proxy_tunnels, + &self.http_tunnels, ) .await?; routes.push(db::redis_driver::RedisNodeRoute { @@ -1743,18 +1756,22 @@ impl AppState { let redis_cluster_prefix = redis_cluster_transport_prefix(connection_id); self.tunnels.stop_tunnels_with_prefix(&redis_cluster_prefix).await; self.proxy_tunnels.stop_tunnels_with_prefix(&redis_cluster_prefix).await; + self.http_tunnels.stop_tunnels_with_prefix(&redis_cluster_prefix).await; let redis_sentinel_prefix = redis_sentinel_transport_prefix(connection_id); self.tunnels.stop_tunnels_with_prefix(&redis_sentinel_prefix).await; self.proxy_tunnels.stop_tunnels_with_prefix(&redis_sentinel_prefix).await; + self.http_tunnels.stop_tunnels_with_prefix(&redis_sentinel_prefix).await; db::transport_layer_tunnel::stop_transport_layers( connection_id, layer_count, &self.tunnels, &self.proxy_tunnels, + &self.http_tunnels, ) .await; self.tunnels.stop_tunnel(connection_id).await; self.proxy_tunnels.stop_tunnel(connection_id).await; + self.http_tunnels.stop_tunnel(connection_id).await; } /// Health-check the base connection pool for a given connection_id. diff --git a/crates/dbx-core/src/connection_secrets.rs b/crates/dbx-core/src/connection_secrets.rs index a2b75d33b..1b06addeb 100644 --- a/crates/dbx-core/src/connection_secrets.rs +++ b/crates/dbx-core/src/connection_secrets.rs @@ -188,6 +188,9 @@ fn persist_transport_layer_secrets( TransportLayerConfig::Proxy(proxy) => { persist_secret(store, connection_id, &transport_layer_proxy_password_key(index, layer), &proxy.password)?; } + TransportLayerConfig::HttpTunnel(http) => { + persist_secret(store, connection_id, &transport_layer_http_tunnel_token_key(index, layer), &http.token)?; + } } Ok(()) } @@ -244,6 +247,17 @@ fn hydrate_transport_layer_secrets( *needs_rewrite = true; } } + TransportLayerConfig::HttpTunnel(http) => { + let token_key = transport_layer_http_tunnel_token_key(index, &layer_for_key); + if http.token.is_empty() { + if let Some(secret) = store.get_secret(&config.id, &token_key)? { + http.token = secret; + } + } else { + store.set_secret(&config.id, &token_key, &http.token)?; + *needs_rewrite = true; + } + } } } Ok(()) @@ -584,6 +598,10 @@ fn transport_layer_proxy_password_key(index: usize, layer: &TransportLayerConfig format!("{}{}.proxy_password", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) } +fn transport_layer_http_tunnel_token_key(index: usize, layer: &TransportLayerConfig) -> String { + format!("{}{}.http_tunnel_token", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) +} + fn read_connections(path: &Path) -> Result, String> { let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?; serde_json::from_str(&json).map_err(|e| e.to_string()) @@ -610,6 +628,9 @@ fn sanitize_connections(configs: &[ConnectionConfig]) -> Vec { TransportLayerConfig::Proxy(proxy) => { proxy.password.clear(); } + TransportLayerConfig::HttpTunnel(http) => { + http.token.clear(); + } } } config.redis_sentinel_password.clear(); @@ -632,7 +653,9 @@ mod tests { MAIN_PASSWORD_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, REDIS_SENTINEL_PASSWORD_KEY, SSH_PASSWORD_KEY, }; - use crate::models::connection::{ConnectionConfig, DatabaseType, SshTunnelConfig, TransportLayerConfig}; + use crate::models::connection::{ + ConnectionConfig, DatabaseType, HttpTunnelConfig, SshTunnelConfig, TransportLayerConfig, + }; use std::cell::RefCell; use std::collections::HashMap; use std::path::Path; @@ -758,6 +781,17 @@ mod tests { } } + fn http_tunnel(id: &str, token: &str) -> TransportLayerConfig { + TransportLayerConfig::HttpTunnel(HttpTunnelConfig { + id: id.to_string(), + name: String::new(), + enabled: true, + url: "https://dbx.example.com/dbx_tunnel.php".to_string(), + token: token.to_string(), + connect_timeout_secs: 10, + }) + } + fn read_configs(path: &Path) -> Vec { let json = std::fs::read_to_string(path).unwrap(); serde_json::from_str(&json).unwrap() @@ -785,7 +819,7 @@ mod tests { assert_eq!(ssh.password, ""); assert_eq!(ssh.key_passphrase, ""); } - TransportLayerConfig::Proxy(_) => panic!("expected ssh layer"), + _ => panic!("expected ssh layer"), } assert_eq!(persisted[0].redis_sentinel_password, ""); } @@ -812,11 +846,37 @@ mod tests { assert_eq!(ssh.password, "hop-secret"); assert_eq!(ssh.key_passphrase, "hop-key"); } - TransportLayerConfig::Proxy(_) => panic!("expected ssh layer"), + _ => panic!("expected ssh layer"), } assert_eq!(loaded[0].redis_sentinel_password, "sentinel-secret"); } + #[test] + fn save_and_load_connections_move_http_tunnel_token_to_secret_store() { + let path = temp_connections_file("http-tunnel-token"); + let store = MemorySecretStore::default(); + let mut config = connection("main", "", ""); + config.transport_layers = vec![http_tunnel("http", "tunnel-secret")]; + + save_connections_to_file(&path, &[config], &store).unwrap(); + + assert_eq!( + store.get_existing("main", "transport_layers.http.http_tunnel_token").as_deref(), + Some("tunnel-secret") + ); + let persisted = read_configs(&path); + match &persisted[0].transport_layers[0] { + TransportLayerConfig::HttpTunnel(http) => assert_eq!(http.token, ""), + _ => panic!("expected http tunnel layer"), + } + + let loaded = load_connections_from_file(&path, &store).unwrap(); + match &loaded[0].transport_layers[0] { + TransportLayerConfig::HttpTunnel(http) => assert_eq!(http.token, "tunnel-secret"), + _ => panic!("expected http tunnel layer"), + } + } + #[test] fn load_connections_migrates_plaintext_passwords_and_rewrites_sanitized_file() { let path = temp_connections_file("migrates-plaintext"); @@ -843,7 +903,7 @@ mod tests { assert_eq!(loaded[0].password, "plain-db"); match &loaded[0].transport_layers[0] { TransportLayerConfig::Ssh(ssh) => assert_eq!(ssh.password, "plain-ssh"), - TransportLayerConfig::Proxy(_) => panic!("expected ssh layer"), + _ => panic!("expected ssh layer"), } assert_eq!(store.get_existing("legacy", MAIN_PASSWORD_KEY).as_deref(), Some("plain-db")); assert_eq!(store.get_existing("legacy", "transport_layers.legacy.ssh_password").as_deref(), Some("plain-ssh")); @@ -851,7 +911,7 @@ mod tests { assert_eq!(persisted[0].password, ""); match &persisted[0].transport_layers[0] { TransportLayerConfig::Ssh(ssh) => assert_eq!(ssh.password, ""), - TransportLayerConfig::Proxy(_) => panic!("expected ssh layer"), + _ => panic!("expected ssh layer"), } } diff --git a/crates/dbx-core/src/db/http_tunnel.rs b/crates/dbx-core/src/db/http_tunnel.rs new file mode 100644 index 000000000..8a4b51bbc --- /dev/null +++ b/crates/dbx-core/src/db/http_tunnel.rs @@ -0,0 +1,413 @@ +use bytes::Bytes; +use reqwest::header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE}; +use reqwest::{Client, StatusCode, Url}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio::time::{timeout, Duration}; +use uuid::Uuid; + +const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const TCP_READ_BUFFER_SIZE: usize = 16 * 1024; +const HTTP_READ_WAIT_MS: u64 = 1_000; +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Default)] +pub struct HttpTunnelManager { + tunnels: Mutex, u16)>>, +} + +impl HttpTunnelManager { + pub fn new() -> Self { + Self { tunnels: Mutex::new(HashMap::new()) } + } + + pub async fn start_tunnel( + &self, + connection_id: &str, + tunnel_url: &str, + token: &str, + connect_timeout_secs: u64, + remote_host: &str, + remote_port: u16, + ) -> Result { + if let Some(local_port) = self.local_port(connection_id).await { + return Ok(local_port); + } + let local_port = portpicker::pick_unused_port().ok_or("No available port")?; + let listener = TcpListener::bind(("127.0.0.1", local_port)) + .await + .map_err(|e| format!("Failed to bind HTTP tunnel local port: {e}"))?; + + let endpoint = + Arc::new(HttpTunnelEndpoint::new(tunnel_url, token, connect_timeout_secs, remote_host, remote_port)?); + let handle = tokio::spawn(http_tunnel_forward_loop(listener, endpoint)); + + let mut tunnels = self.tunnels.lock().await; + if let Some((_, existing_port)) = tunnels.get(connection_id) { + handle.abort(); + return Ok(*existing_port); + } + + tunnels.insert(connection_id.to_string(), (handle, local_port)); + Ok(local_port) + } + + pub async fn local_port(&self, connection_id: &str) -> Option { + self.tunnels.lock().await.get(connection_id).map(|(_, port)| *port) + } + + pub async fn stop_tunnel(&self, connection_id: &str) { + if let Some((handle, _)) = self.tunnels.lock().await.remove(connection_id) { + handle.abort(); + } + } + + pub async fn stop_tunnels_with_prefix(&self, connection_id_prefix: &str) { + let mut tunnels = self.tunnels.lock().await; + let keys: Vec = tunnels.keys().filter(|key| key.starts_with(connection_id_prefix)).cloned().collect(); + for key in keys { + if let Some((handle, _)) = tunnels.remove(&key) { + handle.abort(); + } + } + } +} + +struct HttpTunnelEndpoint { + url: String, + token: String, + connect_timeout: Duration, + target_host: String, + target_port: u16, + client: Client, +} + +impl HttpTunnelEndpoint { + fn new( + tunnel_url: &str, + token: &str, + connect_timeout_secs: u64, + remote_host: &str, + remote_port: u16, + ) -> Result { + validate_script_url(tunnel_url)?; + Ok(Self { + url: tunnel_url.trim().to_string(), + token: token.trim().to_string(), + connect_timeout: effective_connect_timeout(connect_timeout_secs), + target_host: remote_host.to_string(), + target_port: remote_port, + client: Client::new(), + }) + } +} + +async fn http_tunnel_forward_loop(listener: TcpListener, endpoint: Arc) { + loop { + let (inbound, _) = match listener.accept().await { + Ok(pair) => pair, + Err(_) => break, + }; + let endpoint = endpoint.clone(); + tokio::spawn(async move { + let _ = bridge_tcp_to_http_script(inbound, endpoint).await; + }); + } +} + +async fn bridge_tcp_to_http_script(mut inbound: TcpStream, endpoint: Arc) -> Result<(), String> { + let session = Uuid::new_v4().simple().to_string(); + open_session(&endpoint, &session).await?; + + let (mut tcp_reader, mut tcp_writer) = inbound.split(); + let tcp_to_http = async { + let mut buf = vec![0_u8; TCP_READ_BUFFER_SIZE]; + loop { + let n = tcp_reader.read(&mut buf).await.map_err(|e| format!("Failed to read local TCP stream: {e}"))?; + if n == 0 { + return Ok::<(), String>(()); + } + write_session(&endpoint, &session, Bytes::copy_from_slice(&buf[..n])).await?; + } + }; + + let http_to_tcp = async { + loop { + let bytes = read_session(&endpoint, &session).await?; + if !bytes.is_empty() { + tcp_writer.write_all(&bytes).await.map_err(|e| format!("Failed to write local TCP stream: {e}"))?; + } + } + }; + + let result = tokio::select! { + result = tcp_to_http => result, + result = http_to_tcp => result, + }; + let _ = close_session(&endpoint, &session).await; + result +} + +async fn open_session(endpoint: &HttpTunnelEndpoint, session: &str) -> Result<(), String> { + let url = script_url( + &endpoint.url, + "open", + session, + Some((&endpoint.target_host, endpoint.target_port)), + Some(endpoint.connect_timeout.as_secs()), + None, + )?; + let response = timeout( + endpoint.connect_timeout + HTTP_REQUEST_TIMEOUT, + authorized_request(endpoint, endpoint.client.post(url)).send(), + ) + .await + .map_err(|_| "HTTP tunnel script open timed out".to_string())? + .map_err(|e| format!("Failed to open HTTP tunnel script session: {e}"))?; + ensure_success(response, "open HTTP tunnel script session").await +} + +async fn write_session(endpoint: &HttpTunnelEndpoint, session: &str, data: Bytes) -> Result<(), String> { + let url = script_url(&endpoint.url, "write", session, None, None, None)?; + let response = timeout( + HTTP_REQUEST_TIMEOUT, + authorized_request(endpoint, endpoint.client.post(url)) + .header(CONTENT_TYPE, "application/octet-stream") + .body(data) + .send(), + ) + .await + .map_err(|_| "HTTP tunnel script write timed out".to_string())? + .map_err(|e| format!("Failed to write HTTP tunnel script session: {e}"))?; + ensure_success(response, "write HTTP tunnel script session").await +} + +async fn read_session(endpoint: &HttpTunnelEndpoint, session: &str) -> Result { + let url = script_url(&endpoint.url, "read", session, None, None, Some(HTTP_READ_WAIT_MS))?; + let response = timeout(HTTP_REQUEST_TIMEOUT, authorized_request(endpoint, endpoint.client.post(url)).send()) + .await + .map_err(|_| "HTTP tunnel script read timed out".to_string())? + .map_err(|e| format!("Failed to read HTTP tunnel script session: {e}"))?; + let status = response.status(); + if status == StatusCode::NO_CONTENT { + return Ok(Bytes::new()); + } + if status == StatusCode::OK { + return response.bytes().await.map_err(|e| format!("Failed to read HTTP tunnel script response: {e}")); + } + if status == StatusCode::GONE { + return Err("HTTP tunnel script session closed".to_string()); + } + Err(error_response_message(response, "read HTTP tunnel script session").await) +} + +async fn close_session(endpoint: &HttpTunnelEndpoint, session: &str) -> Result<(), String> { + let url = script_url(&endpoint.url, "close", session, None, None, None)?; + let response = timeout(HTTP_REQUEST_TIMEOUT, authorized_request(endpoint, endpoint.client.post(url)).send()) + .await + .map_err(|_| "HTTP tunnel script close timed out".to_string())? + .map_err(|e| format!("Failed to close HTTP tunnel script session: {e}"))?; + ensure_success(response, "close HTTP tunnel script session").await +} + +fn authorized_request(endpoint: &HttpTunnelEndpoint, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + if endpoint.token.is_empty() { + return builder; + } + match HeaderValue::from_str(&endpoint.token) { + Ok(token) => builder + .header("X-DBX-Tunnel-Token", token.clone()) + .header(AUTHORIZATION, format!("Bearer {}", endpoint.token)), + Err(_) => builder, + } +} + +async fn ensure_success(response: reqwest::Response, action: &str) -> Result<(), String> { + if response.status().is_success() { + Ok(()) + } else { + Err(error_response_message(response, action).await) + } +} + +async fn error_response_message(response: reqwest::Response, action: &str) -> String { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if body.trim().is_empty() { + format!("Failed to {action}: HTTP {status}") + } else { + format!("Failed to {action}: HTTP {status}: {}", body.trim()) + } +} + +fn script_url( + tunnel_url: &str, + action: &str, + session: &str, + target: Option<(&str, u16)>, + connect_timeout_secs: Option, + wait_ms: Option, +) -> Result { + let mut url = validate_script_url(tunnel_url)?; + url.query_pairs_mut().append_pair("dbx_action", action).append_pair("dbx_session", session); + if let Some((host, port)) = target { + url.query_pairs_mut().append_pair("dbx_target_host", host).append_pair("dbx_target_port", &port.to_string()); + } + if let Some(connect_timeout_secs) = connect_timeout_secs { + url.query_pairs_mut() + .append_pair("dbx_connect_timeout", &effective_connect_timeout_secs(connect_timeout_secs).to_string()); + } + if let Some(wait_ms) = wait_ms { + url.query_pairs_mut().append_pair("dbx_wait_ms", &wait_ms.to_string()); + } + Ok(url) +} + +fn validate_script_url(tunnel_url: &str) -> Result { + let trimmed = tunnel_url.trim(); + if trimmed.is_empty() { + return Err("HTTP tunnel script URL is required".to_string()); + } + let url = Url::parse(trimmed).map_err(|e| format!("HTTP tunnel script URL is invalid: {e}"))?; + match url.scheme() { + "http" | "https" => Ok(url), + other => Err(format!("Unsupported HTTP tunnel script URL scheme: {other}")), + } +} + +fn effective_connect_timeout(value: u64) -> Duration { + Duration::from_secs(effective_connect_timeout_secs(value)) +} + +fn effective_connect_timeout_secs(value: u64) -> u64 { + if value == 0 { + DEFAULT_CONNECT_TIMEOUT.as_secs() + } else { + value.clamp(1, 300) + } +} + +#[cfg(test)] +mod tests { + use super::{script_url, validate_script_url, HttpTunnelManager}; + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + use tokio::net::{TcpListener, TcpStream}; + use tokio::sync::Mutex; + + #[test] + fn script_url_preserves_existing_query_and_appends_action() { + let url = script_url( + "https://gateway.example.com/dbx_tunnel.php?keep=1", + "open", + "session-1", + Some(("mysql.internal", 3306)), + Some(10), + None, + ) + .unwrap(); + + assert_eq!( + url.as_str(), + "https://gateway.example.com/dbx_tunnel.php?keep=1&dbx_action=open&dbx_session=session-1&dbx_target_host=mysql.internal&dbx_target_port=3306&dbx_connect_timeout=10" + ); + } + + #[test] + fn script_url_rejects_non_http_schemes() { + let err = validate_script_url("file:///tmp/tunnel.php").unwrap_err(); + + assert!(err.contains("Unsupported HTTP tunnel script URL scheme")); + } + + #[tokio::test] + async fn manager_forwards_bytes_through_http_script_protocol() { + let script = MockScript::start().await; + let manager = HttpTunnelManager::new(); + let local_port = manager.start_tunnel("test", &script.url, "secret", 5, "mysql.internal", 3306).await.unwrap(); + let mut client = TcpStream::connect(("127.0.0.1", local_port)).await.unwrap(); + client.write_all(b"ping").await.unwrap(); + let mut response = [0_u8; 4]; + client.read_exact(&mut response).await.unwrap(); + + assert_eq!(&response, b"ping"); + + manager.stop_tunnel("test").await; + script.handle.abort(); + } + + struct MockScript { + url: String, + handle: tokio::task::JoinHandle<()>, + } + + impl MockScript { + async fn start() -> Self { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let addr = listener.local_addr().unwrap(); + let buffered = std::sync::Arc::new(Mutex::new(Vec::::new())); + let handle = { + let buffered = buffered.clone(); + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let buffered = buffered.clone(); + tokio::spawn(async move { + handle_mock_http_request(stream, buffered).await; + }); + } + }) + }; + Self { url: format!("http://{addr}/dbx_tunnel.php"), handle } + } + } + + async fn handle_mock_http_request(stream: TcpStream, buffered: std::sync::Arc>>) { + let mut reader = BufReader::new(stream); + let mut request_line = String::new(); + reader.read_line(&mut request_line).await.unwrap(); + let mut content_length = 0_usize; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + let trimmed = line.trim_end(); + if trimmed.is_empty() { + break; + } + let lower = trimmed.to_ascii_lowercase(); + if let Some(value) = lower.strip_prefix("content-length:") { + content_length = value.trim().parse().unwrap(); + } + } + let mut body = vec![0_u8; content_length]; + reader.read_exact(&mut body).await.unwrap(); + let mut stream = reader.into_inner(); + if request_line.contains("dbx_action=write") { + buffered.lock().await.extend_from_slice(&body); + write_http_response(&mut stream, "200 OK", b"OK").await; + } else if request_line.contains("dbx_action=read") { + let bytes = { + let mut buffered = buffered.lock().await; + std::mem::take(&mut *buffered) + }; + if bytes.is_empty() { + write_http_response(&mut stream, "204 No Content", b"").await; + } else { + write_http_response(&mut stream, "200 OK", &bytes).await; + } + } else { + write_http_response(&mut stream, "200 OK", b"OK").await; + } + } + + async fn write_http_response(stream: &mut TcpStream, status: &str, body: &[u8]) { + let header = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + stream.write_all(header.as_bytes()).await.unwrap(); + stream.write_all(body).await.unwrap(); + } +} diff --git a/crates/dbx-core/src/db/mod.rs b/crates/dbx-core/src/db/mod.rs index 2654a4b19..35af96fb7 100644 --- a/crates/dbx-core/src/db/mod.rs +++ b/crates/dbx-core/src/db/mod.rs @@ -4,6 +4,7 @@ pub mod duckdb_driver; pub mod elasticsearch_driver; pub mod elasticsearch_sql; pub mod file_validator; +pub mod http_tunnel; pub mod influxdb_driver; pub mod manticoresearch; pub mod mongo_driver; diff --git a/crates/dbx-core/src/db/transport_layer_tunnel.rs b/crates/dbx-core/src/db/transport_layer_tunnel.rs index 82cb04741..a2fb51e8d 100644 --- a/crates/dbx-core/src/db/transport_layer_tunnel.rs +++ b/crates/dbx-core/src/db/transport_layer_tunnel.rs @@ -1,5 +1,6 @@ use crate::models::connection::TransportLayerConfig; +use super::http_tunnel::HttpTunnelManager; use super::proxy_tunnel::ProxyTunnelManager; use super::ssh_tunnel::TunnelManager; @@ -26,10 +27,12 @@ pub async fn start_transport_layers( remote_port: u16, ssh_tunnels: &TunnelManager, proxy_tunnels: &ProxyTunnelManager, + http_tunnels: &HttpTunnelManager, ) -> Result { if layers.is_empty() { return Err("No transport layers configured".to_string()); } + validate_transport_layers(layers)?; let mut next_connect_endpoint: Option = None; let mut final_local_port = 0; @@ -80,6 +83,17 @@ pub async fn start_transport_layers( ) .await .map_err(|err| format!("Proxy layer {} failed: {err}", index + 1))?, + TransportLayerConfig::HttpTunnel(http) => http_tunnels + .start_tunnel( + &layer_id, + &http.url, + &http.token, + http.connect_timeout_secs, + &target_endpoint.host, + target_endpoint.port, + ) + .await + .map_err(|err| format!("HTTP tunnel layer {} failed: {err}", index + 1))?, }; final_local_port = local_port; @@ -94,14 +108,25 @@ pub async fn stop_transport_layers( layer_count: usize, ssh_tunnels: &TunnelManager, proxy_tunnels: &ProxyTunnelManager, + http_tunnels: &HttpTunnelManager, ) { for index in 0..layer_count { let layer_id = layer_id(connection_id, index); ssh_tunnels.stop_tunnel(&layer_id).await; proxy_tunnels.stop_tunnel(&layer_id).await; + http_tunnels.stop_tunnel(&layer_id).await; } } +fn validate_transport_layers(layers: &[TransportLayerConfig]) -> Result<(), String> { + for (index, layer) in layers.iter().enumerate() { + if matches!(layer, TransportLayerConfig::HttpTunnel(_)) && index != 0 { + return Err("HTTP tunnel must be the first transport layer".to_string()); + } + } + Ok(()) +} + fn layer_id(connection_id: &str, index: usize) -> String { format!("{connection_id}:transport:{index}") } @@ -119,6 +144,7 @@ fn effective_ssh_connect_timeout_secs(value: u64) -> u64 { enum PlannedLayerType { Ssh, Proxy, + HttpTunnel, } #[cfg(test)] @@ -154,6 +180,7 @@ fn plan_transport_layers( let layer_type = match layer { TransportLayerConfig::Ssh(_) => PlannedLayerType::Ssh, TransportLayerConfig::Proxy(_) => PlannedLayerType::Proxy, + TransportLayerConfig::HttpTunnel(_) => PlannedLayerType::HttpTunnel, }; planned.push(PlannedTransportLayer { layer_type, @@ -171,8 +198,10 @@ fn plan_transport_layers( #[cfg(test)] mod tests { - use super::{plan_transport_layers, PlannedLayerType, PlannedTransportLayer}; - use crate::models::connection::{ProxyTunnelConfig, ProxyType, SshTunnelConfig, TransportLayerConfig}; + use super::{plan_transport_layers, validate_transport_layers, PlannedLayerType, PlannedTransportLayer}; + use crate::models::connection::{ + HttpTunnelConfig, ProxyTunnelConfig, ProxyType, SshTunnelConfig, TransportLayerConfig, + }; fn ssh_layer(id: &str, host: &str, port: u16) -> TransportLayerConfig { TransportLayerConfig::Ssh(SshTunnelConfig { @@ -205,6 +234,17 @@ mod tests { }) } + fn http_tunnel_layer(id: &str, url: &str) -> TransportLayerConfig { + TransportLayerConfig::HttpTunnel(HttpTunnelConfig { + id: id.to_string(), + name: String::new(), + enabled: true, + url: url.to_string(), + token: String::new(), + connect_timeout_secs: 10, + }) + } + #[test] fn mixed_transport_plan_routes_layers_in_configured_order() { let layers = vec![ @@ -242,4 +282,46 @@ mod tests { ] ); } + + #[test] + fn http_tunnel_must_be_outermost_layer() { + let layers = vec![ + ssh_layer("ssh-a", "bastion-a", 22), + http_tunnel_layer("http", "https://dbx.example.com/dbx_tunnel.php"), + ]; + + let err = validate_transport_layers(&layers).unwrap_err(); + + assert!(err.contains("HTTP tunnel must be the first transport layer")); + } + + #[test] + fn http_tunnel_first_layer_targets_next_layer() { + let layers = vec![ + http_tunnel_layer("http", "https://dbx.example.com/dbx_tunnel.php"), + ssh_layer("ssh-a", "bastion-a", 22), + ]; + + let planned = plan_transport_layers(&layers, "db.internal", 5432, &[41001, 41002]); + + assert_eq!( + planned, + vec![ + PlannedTransportLayer { + layer_type: PlannedLayerType::HttpTunnel, + connect_host: "".to_string(), + connect_port: 0, + remote_host: "bastion-a".to_string(), + remote_port: 22, + }, + 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, + }, + ] + ); + } } diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 2d3527cd3..eeee333ae 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -95,6 +95,8 @@ pub struct ConnectionConfig { pub enum TransportLayerConfig { Ssh(SshTunnelConfig), Proxy(ProxyTunnelConfig), + #[serde(rename = "http_tunnel")] + HttpTunnel(HttpTunnelConfig), } impl TransportLayerConfig { @@ -102,6 +104,7 @@ impl TransportLayerConfig { match self { TransportLayerConfig::Ssh(layer) => &layer.id, TransportLayerConfig::Proxy(layer) => &layer.id, + TransportLayerConfig::HttpTunnel(layer) => &layer.id, } } @@ -109,6 +112,7 @@ impl TransportLayerConfig { match self { TransportLayerConfig::Ssh(layer) => &layer.name, TransportLayerConfig::Proxy(layer) => &layer.name, + TransportLayerConfig::HttpTunnel(layer) => &layer.name, } } @@ -116,6 +120,7 @@ impl TransportLayerConfig { match self { TransportLayerConfig::Ssh(layer) => layer.enabled, TransportLayerConfig::Proxy(layer) => layer.enabled, + TransportLayerConfig::HttpTunnel(layer) => layer.enabled, } } @@ -123,6 +128,9 @@ impl TransportLayerConfig { match self { TransportLayerConfig::Ssh(layer) => (&layer.host, layer.port), TransportLayerConfig::Proxy(layer) => (&layer.host, layer.port), + // HTTP script tunnel layers dial a PHP script URL instead of a host:port + // endpoint, and are validated as the outermost transport layer. + TransportLayerConfig::HttpTunnel(_) => ("", 0), } } } @@ -180,6 +188,22 @@ pub struct ProxyTunnelConfig { pub password: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HttpTunnelConfig { + #[serde(default)] + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub url: String, + #[serde(default)] + pub token: String, + #[serde(default = "default_http_tunnel_connect_timeout_secs")] + pub connect_timeout_secs: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AttachedDatabaseConfig { pub name: String, @@ -198,6 +222,10 @@ pub fn default_ssh_connect_timeout_secs() -> u64 { 5 } +pub fn default_http_tunnel_connect_timeout_secs() -> u64 { + 10 +} + pub fn default_connect_timeout_secs() -> u64 { 10 } @@ -592,7 +620,7 @@ impl ConnectionConfig { .into_iter() .filter_map(|layer| match layer { TransportLayerConfig::Ssh(ssh) => Some(ssh), - TransportLayerConfig::Proxy(_) => None, + TransportLayerConfig::Proxy(_) | TransportLayerConfig::HttpTunnel(_) => None, }) .collect() } @@ -1920,7 +1948,7 @@ mod tests { assert_eq!(proxy.username, "alice"); assert_eq!(proxy.password, "secret"); } - TransportLayerConfig::Ssh(_) => panic!("expected proxy layer"), + _ => panic!("expected proxy layer"), } } diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index a609c80f5..5d774783d 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -458,6 +458,10 @@ fn transport_layer_proxy_password_key(index: usize, layer: &TransportLayerConfig format!("{}{}.proxy_password", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) } +fn transport_layer_http_tunnel_token_key(index: usize, layer: &TransportLayerConfig) -> String { + format!("{}{}.http_tunnel_token", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) +} + fn scrub_transport_layer_secrets(config: &mut ConnectionConfig) { for layer in &mut config.transport_layers { match layer { @@ -468,6 +472,9 @@ fn scrub_transport_layer_secrets(config: &mut ConnectionConfig) { TransportLayerConfig::Proxy(proxy) => { proxy.password.clear(); } + TransportLayerConfig::HttpTunnel(http) => { + http.token.clear(); + } } } } @@ -1091,6 +1098,14 @@ impl Storage { &proxy.password, )?; } + TransportLayerConfig::HttpTunnel(http) => { + persist_secret_in_tx( + &tx, + &config.id, + &transport_layer_http_tunnel_token_key(index, layer), + &http.token, + )?; + } } } persist_secret_in_tx(&tx, &config.id, "redis_sentinel_password", &config.redis_sentinel_password)?; @@ -1154,7 +1169,7 @@ impl Storage { TransportLayerConfig::Ssh(layer) => { self.get_secret(&id, &ssh_tunnel_password_key(index, layer)).await? } - TransportLayerConfig::Proxy(_) => None, + TransportLayerConfig::Proxy(_) | TransportLayerConfig::HttpTunnel(_) => None, }) .unwrap_or_default(); ssh.key_passphrase = self @@ -1167,7 +1182,7 @@ impl Storage { TransportLayerConfig::Ssh(layer) => { self.get_secret(&id, &ssh_tunnel_key_passphrase_key(index, layer)).await? } - TransportLayerConfig::Proxy(_) => None, + TransportLayerConfig::Proxy(_) | TransportLayerConfig::HttpTunnel(_) => None, }) .unwrap_or_default(); } @@ -1183,6 +1198,12 @@ impl Storage { }) .unwrap_or_default(); } + TransportLayerConfig::HttpTunnel(http) => { + http.token = self + .get_secret(&id, &transport_layer_http_tunnel_token_key(index, &layer_for_key)) + .await? + .unwrap_or_default(); + } } } config.redis_sentinel_password = self.get_secret(&id, "redis_sentinel_password").await?.unwrap_or_default(); diff --git a/deploy/dbx_tunnel.php b/deploy/dbx_tunnel.php new file mode 100644 index 000000000..8d6192424 --- /dev/null +++ b/deploy/dbx_tunnel.php @@ -0,0 +1,471 @@ +getMessage()); +} + +function handle_open(): void +{ + $session = request_session(); + $host = request_param('dbx_target_host'); + $port = (int) request_param('dbx_target_port'); + $connectTimeout = request_connect_timeout(); + validate_target($host, $port); + + $dir = session_dir($session); + if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) { + respond_error(500, 'Failed to create tunnel session directory'); + } + file_put_contents($dir . DIRECTORY_SEPARATOR . 'meta.json', json_encode([ + 'target_host' => $host, + 'target_port' => $port, + 'created_at' => time(), + ])); + touch($dir . DIRECTORY_SEPARATOR . 'in.queue'); + touch($dir . DIRECTORY_SEPARATOR . 'out.queue'); + + if (function_exists('fastcgi_finish_request')) { + respond_json(['ok' => true], false); + fastcgi_finish_request(); + run_worker($dir, $host, $port, $connectTimeout); + exit; + } + + if (spawn_worker($dir, $host, $port, $connectTimeout)) { + if (wait_for_worker_start($dir, 2000)) { + respond_json(['ok' => true]); + } + respond_error(500, 'PHP tunnel worker did not start'); + } + + respond_error(500, 'PHP tunnel requires PHP-FPM fastcgi_finish_request or permission to spawn PHP CLI'); +} + +function handle_write(): void +{ + $dir = existing_session_dir(request_session()); + ensure_session_alive($dir); + $data = file_get_contents('php://input'); + if ($data !== false && $data !== '') { + append_chunk($dir, 'in', $data); + } + respond_json(['ok' => true]); +} + +function handle_read(): void +{ + $dir = existing_session_dir(request_session()); + $waitMs = max(0, min(5000, (int) (request_param('dbx_wait_ms', '1000')))); + $deadline = microtime(true) + ($waitMs / 1000); + + do { + $data = drain_chunks($dir, 'out'); + if ($data !== '') { + header('Content-Type: application/octet-stream'); + header('Cache-Control: no-store'); + echo $data; + return; + } + if (is_file($dir . DIRECTORY_SEPARATOR . 'error.txt')) { + respond_error(502, trim((string) file_get_contents($dir . DIRECTORY_SEPARATOR . 'error.txt'))); + } + if (is_file($dir . DIRECTORY_SEPARATOR . 'closed')) { + respond_error(410, 'Tunnel session closed'); + } + usleep(20000); + } while (microtime(true) < $deadline); + + http_response_code(204); +} + +function handle_close(): void +{ + $dir = existing_session_dir(request_session()); + touch($dir . DIRECTORY_SEPARATOR . 'close'); + respond_json(['ok' => true]); +} + +function run_worker(string $dir, string $host, int $port, int $connectTimeout): void +{ + global $DBX_TUNNEL_MAX_SESSION_SECONDS; + + if ($dir === '' || $host === '' || $port <= 0) { + return; + } + ignore_user_abort(true); + set_time_limit(0); + @touch($dir . DIRECTORY_SEPARATOR . 'worker.started'); + + $targetHost = strpos($host, ':') !== false && substr($host, 0, 1) !== '[' ? '[' . $host . ']' : $host; + $socket = @stream_socket_client( + 'tcp://' . $targetHost . ':' . $port, + $errno, + $errstr, + max(1, min(300, $connectTimeout)), + STREAM_CLIENT_CONNECT + ); + if (!$socket) { + write_error($dir, 'Failed to connect target database: ' . $errstr); + mark_closed($dir); + return; + } + + stream_set_blocking($socket, false); + $expiresAt = time() + $DBX_TUNNEL_MAX_SESSION_SECONDS; + $lastActivity = time(); + + try { + while (time() < $expiresAt) { + if (is_file($dir . DIRECTORY_SEPARATOR . 'close')) { + break; + } + + $inbound = drain_chunks($dir, 'in'); + if ($inbound !== '') { + write_all($socket, $inbound); + $lastActivity = time(); + } + + $read = [$socket]; + $write = []; + $except = []; + $ready = @stream_select($read, $write, $except, 0, 200000); + if ($ready === false) { + write_error($dir, 'Failed to poll target database socket'); + break; + } + if ($ready > 0) { + $data = fread($socket, 16384); + if ($data === false) { + write_error($dir, 'Failed to read target database socket'); + break; + } + if ($data === '') { + if (feof($socket)) { + break; + } + } else { + append_chunk($dir, 'out', $data); + $lastActivity = time(); + } + } + + if (time() - $lastActivity > $DBX_TUNNEL_MAX_SESSION_SECONDS) { + break; + } + } + } catch (Throwable $e) { + write_error($dir, $e->getMessage()); + } + + fclose($socket); + mark_closed($dir); +} + +function write_all($socket, string $data): void +{ + $offset = 0; + $length = strlen($data); + while ($offset < $length) { + $written = fwrite($socket, substr($data, $offset)); + if ($written === false) { + throw new RuntimeException('Failed to write target database socket'); + } + if ($written === 0) { + usleep(10000); + continue; + } + $offset += $written; + } +} + +function spawn_worker(string $dir, string $host, int $port, int $connectTimeout): bool +{ + if (!function_exists('popen')) { + return false; + } + $php = getenv('DBX_TUNNEL_PHP') ?: PHP_BINARY; + if ($php === '') { + return false; + } + $cmd = escapeshellarg($php) + . ' ' . escapeshellarg(__FILE__) + . ' --dbx-worker ' . escapeshellarg($dir) + . ' ' . escapeshellarg($host) + . ' ' . escapeshellarg((string) $port) + . ' ' . escapeshellarg((string) $connectTimeout) + . ' > /dev/null 2>&1 &'; + $handle = @popen($cmd, 'r'); + if (!is_resource($handle)) { + return false; + } + @pclose($handle); + return true; +} + +function wait_for_worker_start(string $dir, int $timeoutMs): bool +{ + $deadline = microtime(true) + ($timeoutMs / 1000); + do { + if (is_file($dir . DIRECTORY_SEPARATOR . 'worker.started')) { + return true; + } + usleep(20000); + } while (microtime(true) < $deadline); + return false; +} + +function append_chunk(string $dir, string $name, string $data): void +{ + if ($data === '') { + return; + } + $lock = fopen($dir . DIRECTORY_SEPARATOR . $name . '.lock', 'c'); + if (!$lock) { + throw new RuntimeException('Failed to open tunnel queue lock'); + } + flock($lock, LOCK_EX); + file_put_contents($dir . DIRECTORY_SEPARATOR . $name . '.queue', base64_encode($data) . "\n", FILE_APPEND | LOCK_EX); + flock($lock, LOCK_UN); + fclose($lock); +} + +function drain_chunks(string $dir, string $name): string +{ + $path = $dir . DIRECTORY_SEPARATOR . $name . '.queue'; + if (!is_file($path)) { + return ''; + } + $lock = fopen($dir . DIRECTORY_SEPARATOR . $name . '.lock', 'c'); + if (!$lock) { + throw new RuntimeException('Failed to open tunnel queue lock'); + } + flock($lock, LOCK_EX); + $encoded = (string) file_get_contents($path); + file_put_contents($path, ''); + flock($lock, LOCK_UN); + fclose($lock); + + $decoded = ''; + foreach (explode("\n", $encoded) as $line) { + if ($line === '') { + continue; + } + $chunk = base64_decode($line, true); + if ($chunk !== false) { + $decoded .= $chunk; + } + } + return $decoded; +} + +function require_token(): void +{ + global $DBX_TUNNEL_TOKEN; + + if ($DBX_TUNNEL_TOKEN === '') { + respond_error(503, 'DBX_TUNNEL_TOKEN is not configured'); + } + $provided = request_token(); + if ($provided === '' || !hash_equals($DBX_TUNNEL_TOKEN, $provided)) { + respond_error(401, 'Invalid tunnel token'); + } +} + +function request_token(): string +{ + if (isset($_SERVER['HTTP_X_DBX_TUNNEL_TOKEN'])) { + return trim((string) $_SERVER['HTTP_X_DBX_TUNNEL_TOKEN']); + } + $authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''; + if (stripos($authorization, 'Bearer ') === 0) { + return trim(substr($authorization, 7)); + } + return ''; +} + +function validate_target(string $host, int $port): void +{ + global $DBX_TUNNEL_ALLOWED_HOSTS; + + if ($host === '' || preg_match('/[\x00-\x20]/', $host)) { + respond_error(400, 'Invalid target host'); + } + if ( + !filter_var($host, FILTER_VALIDATE_IP) + && !filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) + ) { + respond_error(400, 'Invalid target host'); + } + if ($port < 1 || $port > 65535) { + respond_error(400, 'Invalid target port'); + } + // Optional allow-list keeps this script from becoming a general internal TCP relay. + if ($DBX_TUNNEL_ALLOWED_HOSTS !== [] && !in_array($host, $DBX_TUNNEL_ALLOWED_HOSTS, true)) { + respond_error(403, 'Target host is not allowed'); + } +} + +function request_session(): string +{ + $session = request_param('dbx_session'); + if (!preg_match('/\A[A-Za-z0-9_-]{8,128}\z/', $session)) { + respond_error(400, 'Invalid tunnel session'); + } + return $session; +} + +function request_param(string $name, string $default = ''): string +{ + if (isset($_GET[$name])) { + return trim((string) $_GET[$name]); + } + if (isset($_POST[$name])) { + return trim((string) $_POST[$name]); + } + return $default; +} + +function request_connect_timeout(): int +{ + $timeout = (int) request_param('dbx_connect_timeout', '10'); + return max(1, min(300, $timeout)); +} + +function session_dir(string $session): string +{ + global $DBX_TUNNEL_DIR; + + ensure_base_dir(); + return rtrim($DBX_TUNNEL_DIR, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $session; +} + +function existing_session_dir(string $session): string +{ + $dir = session_dir($session); + if (!is_dir($dir)) { + respond_error(404, 'Tunnel session not found'); + } + return $dir; +} + +function ensure_session_alive(string $dir): void +{ + if (is_file($dir . DIRECTORY_SEPARATOR . 'error.txt')) { + respond_error(502, trim((string) file_get_contents($dir . DIRECTORY_SEPARATOR . 'error.txt'))); + } + if (is_file($dir . DIRECTORY_SEPARATOR . 'closed')) { + respond_error(410, 'Tunnel session closed'); + } +} + +function ensure_base_dir(): void +{ + global $DBX_TUNNEL_DIR; + + if (!is_dir($DBX_TUNNEL_DIR) && !mkdir($DBX_TUNNEL_DIR, 0700, true) && !is_dir($DBX_TUNNEL_DIR)) { + respond_error(500, 'Failed to create tunnel base directory'); + } +} + +function cleanup_old_sessions(): void +{ + global $DBX_TUNNEL_DIR, $DBX_TUNNEL_MAX_SESSION_SECONDS; + + if (!is_dir($DBX_TUNNEL_DIR)) { + return; + } + foreach (glob(rtrim($DBX_TUNNEL_DIR, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: [] as $dir) { + if (filemtime($dir) !== false && time() - filemtime($dir) > ($DBX_TUNNEL_MAX_SESSION_SECONDS * 2)) { + remove_dir($dir); + } + } +} + +function remove_dir(string $dir): void +{ + foreach (glob($dir . DIRECTORY_SEPARATOR . '*') ?: [] as $path) { + if (is_dir($path)) { + remove_dir($path); + } else { + @unlink($path); + } + } + @rmdir($dir); +} + +function write_error(string $dir, string $message): void +{ + @file_put_contents($dir . DIRECTORY_SEPARATOR . 'error.txt', $message, LOCK_EX); +} + +function mark_closed(string $dir): void +{ + @touch($dir . DIRECTORY_SEPARATOR . 'closed'); +} + +function respond_json(array $payload, bool $exit = true): void +{ + header('Content-Type: application/json'); + header('Cache-Control: no-store'); + echo json_encode($payload); + if ($exit) { + exit; + } +} + +function respond_error(int $status, string $message): void +{ + http_response_code($status); + header('Content-Type: text/plain; charset=utf-8'); + header('Cache-Control: no-store'); + echo $message; + exit; +} diff --git a/docs/content/docs/databases.cn.mdx b/docs/content/docs/databases.cn.mdx index 042199721..71c0aa67a 100644 --- a/docs/content/docs/databases.cn.mdx +++ b/docs/content/docs/databases.cn.mdx @@ -95,7 +95,7 @@ DBX 只会在具备足够元数据和 SQL 生成能力的数据库上启用高 ## 连接选项 -大多数网络数据库支持主机、端口、用户名、密码、默认数据库、URL 参数、SSL、连接颜色、SSH 隧道和代理设置。文件型数据库会改为选择本地文件。 +大多数网络数据库支持主机、端口、用户名、密码、默认数据库、URL 参数、SSL、连接颜色和隧道/代理设置。文件型数据库会改为选择本地文件。 DBX 也可以解析常见连接 URL,例如 MySQL、PostgreSQL、Redis、MongoDB、ClickHouse、SQL Server、Oracle、Elasticsearch、DM、GaussDB、KWDB、openGauss、TDengine、XuguDB、Access、Teradata、Vertica、Firebird、Exasol、GBase、YashanDB、SAP HANA、InfluxDB、QuestDB、IoTDB、etcd、Nacos、RQLite 和 Databricks。 @@ -135,7 +135,7 @@ SQLite 连接可以使用 `:memory:` 作为文件路径,创建临时的内存 安装可选 JDBC 插件并导入厂商驱动 JAR。 - - 连接私有网络或跳板机后的数据库。 + + 连接私有网络、跳板机、代理或 HTTP 隧道脚本后的数据库。 diff --git a/docs/content/docs/databases.mdx b/docs/content/docs/databases.mdx index 7e3a8219a..7424a3d57 100644 --- a/docs/content/docs/databases.mdx +++ b/docs/content/docs/databases.mdx @@ -95,7 +95,7 @@ DBX intentionally enables advanced workflows only where the app has enough metad ## Connection Options -Most network databases support host, port, username, password, default database, optional URL parameters, SSL, connection color, SSH tunnel, and proxy settings. File-based engines replace host and port with a file picker. +Most network databases support host, port, username, password, default database, optional URL parameters, SSL, connection color, and tunnel / proxy settings. File-based engines replace host and port with a file picker. DBX can also parse common connection URLs for engines such as MySQL, PostgreSQL, Redis, MongoDB, ClickHouse, SQL Server, Oracle, Elasticsearch, DM, GaussDB, KWDB, openGauss, TDengine, XuguDB, Access, Teradata, Vertica, Firebird, Exasol, GBase, YashanDB, SAP HANA, InfluxDB, QuestDB, IoTDB, etcd, Nacos, RQLite, and Databricks. @@ -135,7 +135,7 @@ Use the [JDBC Plugin](/en/docs/plugins) when a database is not covered by the bu Install the optional JDBC plugin and import vendor driver JARs. - - Reach databases behind private networks or bastion hosts. + + Reach databases behind private networks, bastion hosts, proxies, or HTTP tunnel scripts. diff --git a/docs/content/docs/getting-started.cn.mdx b/docs/content/docs/getting-started.cn.mdx index 7d80ccd91..926a43e92 100644 --- a/docs/content/docs/getting-started.cn.mdx +++ b/docs/content/docs/getting-started.cn.mdx @@ -131,7 +131,7 @@ description: 安装 DBX、创建第一个连接,并了解桌面版、Docker ### 配置网络选项 - 如果数据库在内网或跳板机后面,可以启用 [SSH 隧道](/cn/docs/ssh-tunnel)。如果网络环境要求 SOCKS5 或 HTTP 代理,可以在代理页签中配置。 + 如果数据库在内网、跳板机后面、只能通过 Web 网关访问,或网络要求 SOCKS5/HTTP 代理,可以配置 [隧道/代理](/cn/docs/ssh-tunnel)。 @@ -173,7 +173,7 @@ description: 安装 DBX、创建第一个连接,并了解桌面版、Docker | 认证失败 | 用户名、密码、认证方式、SSL 要求、账号是否允许远程登录 | | 能连接但看不到表 | 默认数据库、Schema、权限、元数据读取权限、可见数据库过滤 | | 文件数据库打不开 | 文件路径、文件权限、Docker volume 挂载、文件扩展名是否支持 | -| 内网数据库无法访问 | 配置 SSH 隧道、代理、VPN,或让 Docker 部署机器能访问目标数据库 | +| 内网数据库无法访问 | 配置隧道/代理、VPN,或让 Docker 部署机器能访问目标数据库 | ## 从源码运行 diff --git a/docs/content/docs/getting-started.mdx b/docs/content/docs/getting-started.mdx index 311b552bd..3c3c7ad38 100644 --- a/docs/content/docs/getting-started.mdx +++ b/docs/content/docs/getting-started.mdx @@ -131,7 +131,7 @@ This guide gets you through three things: ### Add Network Options - Enable [SSH Tunnel](/en/docs/ssh-tunnel) for databases behind private networks or bastion hosts. Use the proxy tab when your environment requires SOCKS5 or HTTP proxy access. + Use [Tunnel / Proxy](/en/docs/ssh-tunnel) for databases behind private networks, bastion hosts, web-only gateways, or SOCKS5/HTTP proxy environments. @@ -173,7 +173,7 @@ This guide gets you through three things: | Authentication failed | Username, password, auth mode, SSL requirement, remote login permission | | Connected but no tables | Default database, schema, permissions, metadata read access, visible database filter | | File database cannot open | File path, file permissions, mounted Docker volume, or unsupported file extension | -| Private database unreachable | Configure SSH tunnel, proxy, VPN, or run Docker on a machine that can reach the database | +| Private database unreachable | Configure tunnel / proxy, VPN, or run Docker on a machine that can reach the database | ## Run From Source diff --git a/docs/content/docs/ssh-tunnel.cn.mdx b/docs/content/docs/ssh-tunnel.cn.mdx index 44eb8fe2b..72c437469 100644 --- a/docs/content/docs/ssh-tunnel.cn.mdx +++ b/docs/content/docs/ssh-tunnel.cn.mdx @@ -1,63 +1,132 @@ --- -title: SSH 隧道 -description: 通过 SSH 隧道安全连接防火墙后的数据库,支持密码和密钥认证。 +title: 隧道/代理 +description: 通过 SSH 隧道、HTTP 隧道、SOCKS5 代理或 HTTP 代理连接内网数据库。 --- -内置 SSH 隧道,无需在终端手动建立。支持密码和密钥认证,可选暴露给局域网供团队共享。 +当数据库不能直接访问时,DBX 可以在连接窗口的 **隧道/代理** 页签里配置连接层。 -当数据库服务器不允许直接远程访问时(例如在防火墙或私有网络后面),你可以通过 SSH 隧道安全地连接。 +DBX 支持三类隧道/代理: -## 配置 +| 类型 | 适用场景 | +| -------- | --------------------------------------------- | +| SSH 隧道 | 有跳板机、堡垒机或数据库服务器的 SSH 权限。 | +| 代理 | 网络环境要求通过 SOCKS5 或 HTTP CONNECT 代理。 | +| HTTP 隧道 | 只有内网 Web 服务器能访问数据库,不能使用 SSH/VPN。 | -在创建或编辑数据库连接时,启用"SSH 隧道"选项: +对网络数据库来说,DBX 会先在本机打开一个本地端口,让数据库驱动连接这个端口,再由隧道/代理层把数据库协议字节转发到真实目标。 + +## SSH 隧道 + +SSH 隧道是内置能力,不需要在终端里手动执行端口转发命令。 | 字段 | 说明 | | ---------- | ------------------------------------------ | | SSH 主机 | SSH 服务器的地址 | -| SSH 端口 | SSH 端口(默认 22) | -| SSH 用户名 | 登录 SSH 服务器的用户名 | +| SSH 端口 | SSH 端口,默认 `22` | +| SSH 用户 | 登录 SSH 服务器的用户名 | | 连接超时 | DBX 等待 SSH 连接建立的最长时间,默认 5 秒 | -## 认证方式 - - 选择本地的 SSH 私钥文件(如 `~/.ssh/id_rsa`、`~/.ssh/id_ed25519`): + 选择本地的 SSH 私钥文件,例如 `~/.ssh/id_rsa`、`~/.ssh/id_ed25519`: - - **密钥路径** — 点击文件选择器浏览并选择私钥文件 - - **密钥密码** — 如果私钥设置了密码保护,在此输入 + - **密钥路径**:浏览并选择私钥文件。 + - **密钥密码**:如果私钥设置了密码保护,在这里输入。 密钥认证比密码认证更安全,推荐优先使用。 - 输入 SSH 用户的登录密码即可。适用于允许密码登录的服务器。 + 直接输入 SSH 用户的登录密码。适用于允许密码登录的服务器。 -## 允许局域网访问 +### 允许局域网访问 -启用"允许局域网访问"后,同一网络中的其他设备也可以通过你的机器访问该 SSH 隧道。仅在可信网络中启用此选项。 +启用“允许局域网访问”后,同一网络里的其他设备也可以通过你的机器访问转发后的数据库端口。只应在可信网络中启用。 -默认情况下,SSH 隧道仅监听 `localhost`。启用局域网访问后,隧道绑定到 `0.0.0.0`,适用于团队协作或多设备共享数据库连接的场景。 +默认情况下,SSH 隧道只监听 `localhost`。启用局域网访问后会绑定到 `0.0.0.0`,适合临时团队协作或多设备共享访问。 -## 工作原理 +## 代理 -1. DBX 与 SSH 服务器建立连接 -2. 创建本地端口,将流量通过 SSH 隧道转发到数据库服务器 -3. DBX 通过转发的本地端口连接数据库 +如果当前网络要求通过代理访问数据库,可以添加代理层,而不是配置 SSH。 -隧道在数据库连接期间保持活跃,断开连接时自动关闭。 +DBX 支持: -## SSH、代理与文件型数据库 +- SOCKS5 代理 +- HTTP CONNECT 代理 +- 可选代理用户名和密码 -SSH 隧道适用于网络数据库。SQLite 和 Access 是文件型连接,不使用 SSH。DuckDB 通常也使用本地文件,所以只有当当前驱动类型确实需要网络端点时才需要配置 SSH。 +## HTTP 隧道 -如果你的网络需要代理而不是 SSH,可以使用连接窗口里的代理页签。DBX 支持 SOCKS5 和 HTTP 代理,并可选填写用户名和密码。 +功能类型叫 HTTP 隧道。DBX 当前随代码提供的部署脚本是 `dbx_tunnel.php`,所以部署文件是 PHP 实现,但连接类型本身不是“PHP 隧道”。 + +当数据库在内网中,外部只能访问一台内网 Web 服务器,并且不能使用 SSH/VPN 时,可以使用 HTTP 隧道。这类场景和 Navicat、SQL-Front 的 HTTP 隧道类似。 + +### 部署脚本 + +把 `deploy/dbx_tunnel.php` 上传到一台能访问目标数据库的 PHP Web 服务器。 + +在 PHP 服务器上配置环境变量: + +| 变量 | 必填 | 说明 | +| -------------------------------- | ---- | -------------------------------------------- | +| `DBX_TUNNEL_TOKEN` | 是 | 共享 token。DBX 里填写同一个值。 | +| `DBX_TUNNEL_ALLOWED_HOSTS` | 否 | 允许连接的目标主机列表,逗号分隔,强烈建议配置。 | +| `DBX_TUNNEL_DIR` | 否 | 会话队列目录,默认使用临时目录。 | +| `DBX_TUNNEL_MAX_SESSION_SECONDS` | 否 | 会话最长生命周期,默认 `3600` 秒。 | +| `DBX_TUNNEL_PHP` | 否 | 当 PHP-FPM 没有 `fastcgi_finish_request` 时,用于启动 worker 的 PHP CLI 路径。 | + +在 DBX 里添加 **HTTP 隧道** 层,并填写: + +| 字段 | 说明 | +| -------------- | ------------------------------------------------------- | +| 隧道脚本 URL | `dbx_tunnel.php` 的 URL,例如 `https://example.com/dbx_tunnel.php` | +| 隧道 Token | `DBX_TUNNEL_TOKEN` 的值 | +| 隧道超时 | 打开 HTTP 隧道以及连接目标数据库的超时时间 | + +数据库主机和端口要填写“PHP 服务器视角下能访问到的地址”,不一定是你本机能直接访问到的地址。 + +### HTTP 隧道流程 + +```text +DBX 数据库驱动 + -> 127.0.0.1:<本地临时端口> + -> DBX HTTP 隧道客户端 + -> HTTP POST 请求 + -> Web 服务器上的 dbx_tunnel.php + -> PHP 发起到内网数据库的 TCP 连接 + -> 数据库 +``` + +具体协议是短 HTTP 轮询: + +1. DBX 在本机启动一个 TCP 监听端口。 +2. 数据库驱动连接这个本地端口。 +3. DBX 向 `dbx_tunnel.php` 发送 `open`,带上 session id、目标主机、目标端口和连接超时。 +4. PHP 脚本校验 token 和 allow-list,然后启动 worker,worker 打开到数据库的 TCP socket。 +5. 数据库驱动发出的字节由 DBX 通过 `write` 发给脚本。 +6. DBX 通过 `read` 轮询脚本;脚本返回数据库响应字节后,DBX 写回本地数据库驱动连接。 +7. 连接结束时,DBX 发送 `close`,PHP worker 关闭数据库 socket。 + +这个方案不需要部署 DBX Web,也不使用 WebSocket。 + +### 安全与限制 + +- 脚本 URL 建议使用 HTTPS。 +- `DBX_TUNNEL_TOKEN` 应使用足够长的随机值。 +- 建议配置 `DBX_TUNNEL_ALLOWED_HOSTS`,避免脚本变成通用内网 TCP 转发入口。 +- 如果条件允许,限制脚本路径的公网访问范围。 +- HTTP 隧道部署方便,但通常比直连、VPN、SSH 慢,因为它通过 HTTP 轮询和服务器端队列搬运数据库字节流。 +- 如果和其他连接层一起使用,HTTP 隧道必须放在隧道/代理第一层,因为它是最外层网络边界。 + +## 文件型数据库 + +SQLite 和 Access 是文件型连接,不使用隧道/代理。DuckDB 通常也使用本地文件,所以只有当当前驱动类型确实需要网络端点时才需要配置隧道/代理。 - 在同一个连接窗口里配置数据库类型、SSH、代理、SSL 和连接颜色。 + 在同一个连接窗口里配置数据库类型、隧道/代理、SSL 和连接颜色。 查看哪些数据库是文件型、原生驱动、兼容协议或插件驱动。 diff --git a/docs/content/docs/ssh-tunnel.mdx b/docs/content/docs/ssh-tunnel.mdx index f27d85a6b..85dbf417e 100644 --- a/docs/content/docs/ssh-tunnel.mdx +++ b/docs/content/docs/ssh-tunnel.mdx @@ -1,15 +1,23 @@ --- -title: SSH Tunnel -description: Connect securely to databases behind firewalls via SSH tunnel. Password and key authentication. +title: Tunnel / Proxy +description: Connect to private databases through SSH tunnels, HTTP tunnels, SOCKS5 proxies, or HTTP proxies. --- -Built-in SSH tunnel — no manual terminal setup. Supports password and private key authentication, with optional LAN exposure for team sharing. +DBX can open connection layers for databases that are not directly reachable. Configure them from the connection dialog's **Tunnel / Proxy** tab. -DBX can connect to databases through an SSH tunnel, allowing secure access to servers behind firewalls or on private networks. +DBX supports three tunnel / proxy types: -## Configuration +| Type | Use When | +| ----------- | ------------------------------------------------------------------------ | +| SSH tunnel | You have SSH access to a bastion host or database server. | +| Proxy | Your network requires SOCKS5 or HTTP CONNECT proxy access. | +| HTTP tunnel | Only a web server can reach the database, and SSH/VPN is not available. | -When creating or editing a connection, enable the SSH tunnel option: +For network databases, DBX opens a local port and points the database driver at that port. The tunnel / proxy layer then forwards the database protocol bytes to the real target. + +## SSH Tunnel + +SSH tunnels are built in. You do not need to create a manual terminal port forward. | Field | Description | | --------------- | ----------------------------------------------------------------------------- | @@ -18,46 +26,107 @@ When creating or editing a connection, enable the SSH tunnel option: | SSH User | Username for SSH authentication | | Connect Timeout | Maximum time DBX waits while opening the SSH connection; default is 5 seconds | -## Authentication Methods - - Select a private key file (e.g., `~/.ssh/id_rsa`, `~/.ssh/id_ed25519`): + Select a private key file (for example `~/.ssh/id_rsa` or `~/.ssh/id_ed25519`): - - **Key Path** — Use the file picker to browse and select your key file - - **Key Passphrase** — Enter the passphrase if your key is password-protected + - **Key Path** — Browse and select your key file. + - **Key Passphrase** — Enter the passphrase if your key is encrypted. Key authentication is more secure than password authentication. - Enter your SSH password directly. Works on servers that allow password authentication. + Enter your SSH password directly. This works on servers that allow password authentication. -## Expose Tunnel to LAN +### Expose Tunnel to LAN -Enabling LAN exposure means any device on the same network can access the tunneled database port. Only enable this on trusted networks. +Enabling LAN exposure means other devices on the same network can access the forwarded database port through your machine. Only enable this on trusted networks. -By default, the SSH tunnel listens on `localhost` only. Enable LAN exposure to bind to `0.0.0.0`, making the tunnel accessible to other devices on your network — useful for team collaboration or multi-device access. +By default, the SSH tunnel listens on `localhost` only. Enable LAN exposure to bind to `0.0.0.0`, which is useful for temporary team sharing or multi-device access. -## How It Works +## Proxy -1. DBX establishes an SSH connection to the specified server -2. Creates a local port that forwards traffic through the tunnel to the database -3. Connects to the database through the forwarded local port +Use a proxy layer when your environment requires an outbound proxy instead of SSH. -The tunnel remains active for the duration of the connection and closes automatically when you disconnect. +DBX supports: -## SSH, Proxy, And File Databases +- SOCKS5 proxy +- HTTP CONNECT proxy +- Optional proxy username and password -SSH tunnels are available for network databases. SQLite and Access connections are file-based and do not use SSH. DuckDB usually uses a local file as well, so only configure SSH when the selected driver profile actually needs a network endpoint. +## HTTP Tunnel -If your network requires a proxy instead of SSH, use the connection dialog's proxy tab. DBX supports SOCKS5 and HTTP proxy settings with optional username and password. +The feature is an HTTP tunnel. The script currently provided by DBX is `dbx_tunnel.php`, so the deployment artifact is PHP-specific, but the connection type in DBX is HTTP tunnel. + +Use HTTP tunnel when the database is inside a private network, but a web server in that network can reach it. This matches environments where only HTTP/HTTPS is available and SSH tunnel permissions are too broad or unavailable. + +### Deploy The Script + +Upload `deploy/dbx_tunnel.php` to a PHP web server that can reach the database. + +Configure these environment variables on the PHP server: + +| Variable | Required | Description | +| -------------------------------- | -------- | --------------------------------------------------------------------------- | +| `DBX_TUNNEL_TOKEN` | Yes | Shared token. Enter the same value in DBX. | +| `DBX_TUNNEL_ALLOWED_HOSTS` | No | Comma-separated target host allow-list. Strongly recommended. | +| `DBX_TUNNEL_DIR` | No | Session queue directory. Defaults to a temp directory. | +| `DBX_TUNNEL_MAX_SESSION_SECONDS` | No | Maximum session lifetime. Defaults to `3600`. | +| `DBX_TUNNEL_PHP` | No | PHP CLI path used when PHP-FPM `fastcgi_finish_request` is not available. | + +In DBX, add an **HTTP Tunnel** layer and fill: + +| Field | Description | +| ----------------- | ------------------------------------------------------------------ | +| Tunnel Script URL | URL of `dbx_tunnel.php`, for example `https://example.com/dbx_tunnel.php` | +| Tunnel Token | Value of `DBX_TUNNEL_TOKEN` | +| Tunnel Timeout | Timeout for opening the HTTP tunnel and target database connection | + +The database host and port should be the address as seen from the PHP server, not necessarily the address as seen from your laptop. + +### HTTP Tunnel Flow + +```text +DBX database driver + -> 127.0.0.1: + -> DBX HTTP tunnel client + -> HTTP POST requests + -> dbx_tunnel.php on the web server + -> TCP connection from PHP to the private database + -> database +``` + +The request protocol is short HTTP polling: + +1. DBX starts a local TCP listener. +2. The database driver connects to that local port. +3. DBX sends `open` to `dbx_tunnel.php` with a session id, target host, target port, and connect timeout. +4. The PHP script validates the token and allow-list, then starts a worker that opens a TCP socket to the database. +5. Bytes from the database driver are sent to the script with `write`. +6. DBX polls the script with `read`; response bytes are written back to the local database driver connection. +7. When the connection ends, DBX sends `close` and the PHP worker closes the database socket. + +This does not require DBX Web and does not use WebSocket. + +### Security And Limits + +- Use HTTPS for the script URL. +- Set a long random `DBX_TUNNEL_TOKEN`. +- Set `DBX_TUNNEL_ALLOWED_HOSTS` so the script cannot become a general internal TCP relay. +- Restrict public access to the script path where possible. +- HTTP tunnel is convenient, but it is usually slower than direct TCP, VPN, or SSH because it forwards bytes through HTTP polling and a server-side queue. +- If combined with other layers, HTTP tunnel must be the first tunnel / proxy layer because it is the outermost network boundary. + +## File Databases + +SQLite and Access connections are file-based and do not use tunnel / proxy layers. DuckDB usually uses a local file as well, so only configure tunnel / proxy layers when the selected driver profile uses a network endpoint. - Configure the database profile, SSH, proxy, SSL, and connection color together. + Configure the database profile, tunnel / proxy layers, SSL, and connection color together. Check which database profiles are file-based, native, compatibility-based, or plugin-backed.