diff --git a/.github/pr-assets/navicat-tunnel-ui.png b/.github/pr-assets/navicat-tunnel-ui.png new file mode 100644 index 0000000000..1a97ac98c5 Binary files /dev/null and b/.github/pr-assets/navicat-tunnel-ui.png differ diff --git a/Cargo.lock b/Cargo.lock index fdcf496894..d3d4af1f35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2471,10 +2471,12 @@ dependencies = [ name = "dbx-driver-support" version = "0.1.0" dependencies = [ + "base64 0.22.1", "bytes", "dbx-platform", "dbx-types", "glob", + "log", "portpicker", "reqwest 0.12.28", "russh", diff --git a/agents/drivers/duckdb/Cargo.lock b/agents/drivers/duckdb/Cargo.lock index a6912a495f..4de6fb5092 100644 --- a/agents/drivers/duckdb/Cargo.lock +++ b/agents/drivers/duckdb/Cargo.lock @@ -1482,10 +1482,12 @@ dependencies = [ name = "dbx-driver-support" version = "0.1.0" dependencies = [ + "base64 0.22.1", "bytes", "dbx-platform", "dbx-types", "glob", + "log", "portpicker", "reqwest", "russh", @@ -3426,6 +3428,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minijinja" version = "2.21.0" @@ -4754,6 +4762,7 @@ dependencies = [ "bytes", "cookie", "cookie_store", + "encoding_rs", "futures-core", "futures-util", "http", @@ -4764,6 +4773,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", "quinn", diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 8e109929ea..821a66d1b9 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -581,6 +581,7 @@ function defaultHttpTunnel(): HttpTunnelConfig { enabled: true, url: "", token: "", + protocol: "dbx_tcp", connect_timeout_secs: 10, }; } @@ -606,6 +607,7 @@ function normalizeHttpTunnel(layer: Partial): HttpTunnelConfig enabled: layer.enabled !== false, url: layer.url || "", token: layer.token || "", + protocol: layer.protocol === "navicat_mysql" ? "navicat_mysql" : "dbx_tcp", connect_timeout_secs: Number(layer.connect_timeout_secs) || 10, profile_id: layer.profile_id || undefined, }; @@ -6603,6 +6605,32 @@ function updateSelectedProxyType(value: unknown) { resetTestState(); } +function updateSelectedHttpTunnelProtocol(value: unknown) { + const layer = selectedHttpTunnelLayer.value; + if (!layer) return; + layer.protocol = String(value) === "navicat_mysql" ? "navicat_mysql" : "dbx_tcp"; + if (layer.protocol === "navicat_mysql") layer.token = ""; + if (layer.protocol === "navicat_mysql" && layer.url.trim()) { + try { + const url = new URL(layer.url.trim()); + if (url.pathname.endsWith("/")) { + url.pathname += "ntunnel_mysql.php"; + layer.url = url.toString(); + } else { + layer.url = url.toString(); + } + } catch { + // Keep invalid input unchanged so the submit validation can report it. + } + } + resetTestState(); +} + +const selectedHttpTunnelProtocol = computed({ + get: () => selectedHttpTunnelLayer.value?.protocol || "dbx_tcp", + set: (value: string) => updateSelectedHttpTunnelProtocol(value), +}); + function updateSelectedSshAuthMethod(value: unknown) { const layer = selectedSshLayer.value; if (!layer) return; @@ -7871,7 +7899,7 @@ function openExternalUrl(url: string) {
- + @@ -10645,10 +10673,10 @@ function openExternalUrl(url: string) { -
-
+
+
-
+
@@ -11325,6 +11385,34 @@ function openExternalUrl(url: string) { grid-auto-rows: max-content; } +/* The transport editor contains nested four-column rows. Keep the outer + * scrolling container strictly vertical so legacy WebViews cannot auto-place + * the rows beside each other when a tunnel protocol changes its fields. */ +.connection-transport-form-body { + display: flex !important; + flex-direction: column !important; + align-items: stretch !important; + align-content: stretch !important; +} + +.connection-transport-form-body > .connection-label-wide-grid, +.connection-transport-form-body > .grid { + width: 100% !important; + min-width: 0 !important; + flex: 0 0 auto !important; +} + +.connection-transport-form-body > .grid.grid-cols-4, +.connection-transport-form-body > .connection-label-wide-grid.grid-cols-4 { + grid-template-columns: minmax(7rem, 0.8fr) repeat(3, minmax(0, 1fr)) !important; +} + +.connection-transport-form-body > .grid.grid-cols-4 > .col-span-3, +.connection-transport-form-body > .connection-label-wide-grid.grid-cols-4 > .col-span-3 { + grid-column: 2 / -1 !important; + min-width: 0 !important; +} + @media (max-height: 720px) { .connection-dialog-content--config { /* A definite flex height lets tab bodies shrink and scroll above the fixed footer. */ diff --git a/apps/desktop/src/components/connection/TunnelProfileManager.vue b/apps/desktop/src/components/connection/TunnelProfileManager.vue index fc7b391c1d..e73339854a 100644 --- a/apps/desktop/src/components/connection/TunnelProfileManager.vue +++ b/apps/desktop/src/components/connection/TunnelProfileManager.vue @@ -134,6 +134,26 @@ function updateProxyType(value: unknown) { profile.proxy_type = value === "http" ? "http" : "socks5"; } +function updateHttpTunnelProtocol(value: unknown) { + const profile = selectedHttp.value; + if (!profile) return; + profile.protocol = String(value) === "navicat_mysql" ? "navicat_mysql" : "dbx_tcp"; + if (profile.protocol === "navicat_mysql") profile.token = ""; + if (profile.protocol === "navicat_mysql" && profile.url.trim()) { + try { + const url = new URL(profile.url.trim()); + if (url.pathname.endsWith("/")) { + url.pathname += "ntunnel_mysql.php"; + profile.url = url.toString(); + } else { + profile.url = url.toString(); + } + } catch { + // Keep invalid input unchanged so save/connect validation can report it. + } + } +} + async function browseSshKeyPath(target?: { key_path?: string } | null) { if (isTauriRuntime()) { const { open } = await import("@tauri-apps/plugin-dialog"); @@ -244,20 +264,22 @@ async function testSelected() {
-
+
- +
+ +
@@ -303,17 +325,19 @@ async function testSelected() { diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts index 9c66561b21..98dd72f793 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; import { useNavigationTargets } from "@/composables/useNavigationTargets"; import { clearTableMetadataCache } from "@/lib/metadata/tableMetadataCache"; import type { QueryTab } from "@/types/database"; @@ -87,6 +88,10 @@ vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { dataTabReuseMode: "same-table", tableOpenSortMode: mocks.tableOpenSortMode, tableDatabaseSortDirection: "desc", tableLocalSortDirection: "asc" } }), })); +vi.mock("@/stores/tunnelProfileStore", () => ({ + useTunnelProfileStore: () => ({ profileById: () => undefined }), +})); + vi.mock("@/lib/table/tableSelectSql", async (importOriginal) => ({ ...(await importOriginal()), buildTableSelectSql: mocks.buildTableSelectSql, @@ -104,6 +109,7 @@ function column(name: string) { describe("useNavigationTargets openTableTarget", () => { beforeEach(() => { + setActivePinia(createPinia()); clearTableMetadataCache(); vi.clearAllMocks(); mocks.tabs.length = 0; diff --git a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts index 523def77f4..145aadfa10 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts @@ -109,6 +109,10 @@ vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { tableOpenSortMode: mocks.tableOpenSortMode, tableDatabaseSortDirection: "desc", tableLocalSortDirection: "asc", dataTabReuseMode: mocks.dataTabReuseMode, openDataTabsNextToActive: mocks.openDataTabsNextToActive, pageSize: 100 } }), })); +vi.mock("@/stores/tunnelProfileStore", () => ({ + useTunnelProfileStore: () => ({ profileById: () => undefined }), +})); + vi.mock("@/lib/database/jdbcDialect", () => ({ effectiveDatabaseTypeForConnection: () => mocks.databaseType, connectionObjectTreeNodeSchema: (_config: unknown, _database: string, schema?: string) => schema, diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index d2b9278e6c..87470e02bf 100644 --- a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts @@ -2,6 +2,7 @@ import { nextTick } from "vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; +import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; import type { TreeNode } from "@/types/database"; import { uuid } from "@/lib/common/utils"; import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog"; @@ -30,6 +31,7 @@ export function useSidebarDataOpenRuntime() { const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); + const tunnelProfileStore = useTunnelProfileStore(); async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMode: DataTabOpenMode = "default", options: NavigationOpenOptions & { reuseMode?: DataTabReuseMode; revealColumn?: string } = {}) { if (options.isCurrent?.() === false) return; @@ -92,6 +94,18 @@ export function useSidebarDataOpenRuntime() { const querySchema = config ? connectionObjectTreeQuerySchema(config, node.database, tableSchema) : (tableSchema ?? ""); const effectiveDbType = effectiveDatabaseTypeForConnection(config); const metadataDatabaseType = effectiveDbType || config?.db_type || ""; + // Navicat's PHP tunnel turns every pooled MySQL session into a separate + // HTTP request. Loading columns and indexes before the first data page + // therefore creates several competing handshakes and makes the table + // appear stuck in "loading" even when SELECT itself is already fast. + // Keep the existing eager metadata behavior for every other transport. + const isNavicatMysqlTunnel = + config?.transport_layers?.some((layer) => { + if (layer.type !== "http_tunnel") return false; + if (layer.protocol === "navicat_mysql") return true; + const profile = tunnelProfileStore.profileById(layer.profile_id); + return profile?.type === "http_tunnel" && profile.protocol === "navicat_mysql"; + }) ?? false; const dataTabTarget = { connectionId: node.connectionId, database: node.database, @@ -371,7 +385,7 @@ export function useSidebarDataOpenRuntime() { logPhase("metadata-started", { tabId }); } - const metadataRefresh = shouldRefreshTableMeta && !deferTableMetaRefresh ? refreshTableMetaInBackground(tabId) : undefined; + const metadataRefresh = shouldRefreshTableMeta && !deferTableMetaRefresh && !isNavicatMysqlTunnel ? refreshTableMetaInBackground(tabId) : undefined; if (!cachedTableMeta && (requiresEagerTableMetadataForDataOpen(effectiveDbType) || defaultSortMode !== "none")) { // No query is in flight yet, so deferred drivers can safely load metadata serially. if (deferTableMetaRefresh) await refreshTableMetaInBackground(tabId); @@ -440,9 +454,9 @@ export function useSidebarDataOpenRuntime() { applyTableDefaultSortResult(sortedTab, defaultSort, queryStore.sortTabResultLocally); } logPhase("execute-tab-sql", { tabId }); - if (shouldRefreshTableMeta && deferTableMetaRefresh && canApplyTableMetadata(tabId)) { + if (shouldRefreshTableMeta && (deferTableMetaRefresh || isNavicatMysqlTunnel) && canApplyTableMetadata(tabId)) { void refreshTableMetaInBackground(tabId); - logPhase("metadata-started", { tabId }); + logPhase("metadata-started", { tabId, deferredForTransport: isNavicatMysqlTunnel ? "navicat_mysql" : "driver" }); } } catch (e: any) { if (!isActive()) { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 1c9ff8c623..b46414ce1d 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2023,7 +2023,14 @@ export default { httpTunnel: "HTTP Tunnel", httpTunnelAdd: "Add HTTP tunnel", httpTunnelUrl: "Tunnel Script URL", + httpTunnelProtocol: "Tunnel Protocol", + httpTunnelProtocolDbx: "DBX TCP script", + httpTunnelProtocolNavicat: "Navicat MySQL HTTP", + httpTunnelNavicatHint: + "MySQL only. Get ntunnel_mysql.php from your Navicat installation, upload it to a PHP server, and enter its full URL. HTTP and HTTPS are supported; HTTP sends credentials and SQL in cleartext, so use it only on a trusted private network. If the server redirects, enter the final HTTPS URL. MySQL SSL and other tunnel layers are not supported.", + httpTunnelNavicatScriptHelp: "Where to get the official PHP script", httpTunnelToken: "Tunnel Token", + httpTunnelNavicatNoToken: "Not used by the Navicat protocol", httpTunnelTokenPlaceholder: "Token configured in dbx_tunnel.php", httpTunnelConnectTimeout: "Tunnel Timeout (seconds)", httpTunnelDefaultName: "HTTP Tunnel {index}", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 0e622c00db..5068e10368 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1917,6 +1917,12 @@ export default withEnglishFallback({ pluginSshKeyPickerEmpty: "No se encontraron claves", pluginSshKeyEncrypted: "Cifrado", objectCacheRefreshFailed: "Error al actualizar la caché de objetos: {message}", + httpTunnelProtocol: "Protocolo de túnel", + httpTunnelProtocolDbx: "Script TCP de DBX", + httpTunnelProtocolNavicat: "Navicat MySQL HTTP", + httpTunnelNavicatHint: "Solo compatible con MySQL; admite direcciones HTTP/HTTPS, se recomienda usar HTTPS. No se puede habilitar MySQL SSL u otro túnel al mismo tiempo.", + httpTunnelNavicatNoToken: "El protocolo Navicat no utiliza Token", + httpTunnelNavicatScriptHelp: "Ver cómo obtener el script oficial de PHP", redisCaCert: "Certificado de CA (opcional)", redisCaCertPlaceholder: "Ruta del certificado CA en formato PEM", redisCaCertBrowse: "Seleccionar certificado CA de Redis", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 902b1bc039..f378cd15cc 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1826,6 +1826,12 @@ export default withEnglishFallback({ pluginSshKeyPickerEmpty: "Nessuna chiave trovata", pluginSshKeyEncrypted: "Crittografata", objectCacheRefreshFailed: "Aggiornamento della cache degli oggetti non riuscito: {message}", + httpTunnelProtocol: "Protocollo tunnel", + httpTunnelProtocolDbx: "Script DBX TCP", + httpTunnelProtocolNavicat: "Navicat MySQL HTTP", + httpTunnelNavicatHint: "Supporta solo MySQL; supporta indirizzi HTTP/HTTPS, si consiglia HTTPS. Non è possibile abilitare contemporaneamente MySQL SSL o un altro tunnel.", + httpTunnelNavicatNoToken: "Il protocollo Navicat non utilizza Token", + httpTunnelNavicatScriptHelp: "Scopri come ottenere lo script PHP ufficiale", redisCaCert: "Certificato CA (opzionale)", redisCaCertPlaceholder: "Percorso del certificato CA in formato PEM", redisCaCertBrowse: "Seleziona certificato CA Redis", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 185876550d..d3d8f73836 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1847,6 +1847,12 @@ export default withEnglishFallback({ pluginSshKeyPickerEmpty: "キーが見つかりません", pluginSshKeyEncrypted: "暗号化済み", objectCacheRefreshFailed: "オブジェクトキャッシュの更新に失敗しました:{message}", + httpTunnelProtocol: "トンネルプロトコル", + httpTunnelProtocolDbx: "DBX TCP スクリプト", + httpTunnelProtocolNavicat: "Navicat MySQL HTTP", + httpTunnelNavicatHint: "MySQL のみ対応;HTTP/HTTPS アドレスをサポートし、HTTPS の使用を推奨します。MySQL SSL または他のトンネルと同時に有効にできません。", + httpTunnelNavicatNoToken: "Navicat プロトコルでは Token を使用しません", + httpTunnelNavicatScriptHelp: "公式PHPスクリプトの取得方法を確認", redisCaCert: "CA 証明書(任意)", redisCaCertPlaceholder: "PEM 形式の CA 証明書パス", redisCaCertBrowse: "Redis CA 証明書を選択", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 6363768c4d..12232b8679 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1749,6 +1749,12 @@ export default withEnglishFallback({ mqttDirectionFilterAll: "전체", mqttDirectionFilterReceived: "수신됨", mqttDirectionFilterSent: "발신됨", + httpTunnelProtocol: "터널 프로토콜", + httpTunnelProtocolDbx: "DBX TCP 스크립트", + httpTunnelProtocolNavicat: "Navicat MySQL HTTP", + httpTunnelNavicatHint: "MySQL만 지원합니다. HTTP/HTTPS 주소를 지원하며 HTTPS 사용을 권장합니다. MySQL SSL 또는 다른 터널을 동시에 활성화할 수 없습니다.", + httpTunnelNavicatNoToken: "Navicat 프로토콜은 Token을 사용하지 않습니다", + httpTunnelNavicatScriptHelp: "공식 PHP 스크립트 가져오기 방법 보기", redisCaCert: "CA 인증서(선택 사항)", redisCaCertPlaceholder: "PEM 형식 CA 인증서 경로", redisCaCertBrowse: "Redis CA 인증서 선택", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 0b73412571..f70bd25eac 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1827,6 +1827,12 @@ export default withEnglishFallback({ pluginSshKeyPickerEmpty: "Nenhuma chave encontrada", pluginSshKeyEncrypted: "Criptografada", objectCacheRefreshFailed: "Falha ao atualizar o cache de objetos: {message}", + httpTunnelProtocol: "Protocolo de túnel", + httpTunnelProtocolDbx: "Script TCP DBX", + httpTunnelProtocolNavicat: "Navicat MySQL HTTP", + httpTunnelNavicatHint: "Somente MySQL é suportado; endereços HTTP/HTTPS são suportados, recomenda-se usar HTTPS. Não é possível habilitar MySQL SSL ou outro túnel ao mesmo tempo.", + httpTunnelNavicatNoToken: "O protocolo Navicat não usa Token", + httpTunnelNavicatScriptHelp: "Ver como obter o script PHP oficial", redisCaCert: "Certificado CA (opcional)", redisCaCertPlaceholder: "Caminho do certificado CA no formato PEM", redisCaCertBrowse: "Selecionar certificado CA do Redis", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index 1d4c5da048..157c85050b 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -1827,6 +1827,12 @@ export default withEnglishFallback({ cancelConnecting: "Отменить подключение", connectCancelled: "Подключение отменено", sshTotpCancelled: "Ввод одноразового кода отменён", + httpTunnelProtocol: "Протокол туннеля", + httpTunnelProtocolDbx: "Скрипт DBX TCP", + httpTunnelProtocolNavicat: "Navicat MySQL HTTP", + httpTunnelNavicatHint: "Поддерживается только MySQL; поддерживаются адреса HTTP/HTTPS, рекомендуется использовать HTTPS. Нельзя одновременно включать MySQL SSL или другой туннель.", + httpTunnelNavicatNoToken: "Протокол Navicat не использует токен", + httpTunnelNavicatScriptHelp: "Посмотреть, как получить официальный PHP-скрипт", redisCaCert: "Сертификат CA (необязательно)", redisCaCertPlaceholder: "Путь к сертификату CA в формате PEM", redisCaCertBrowse: "Выбрать CA-сертификат Redis", diff --git a/apps/desktop/src/i18n/locales/th.ts b/apps/desktop/src/i18n/locales/th.ts index 5b8202442f..dd01354b18 100644 --- a/apps/desktop/src/i18n/locales/th.ts +++ b/apps/desktop/src/i18n/locales/th.ts @@ -2024,6 +2024,12 @@ export default withEnglishFallback({ cancelConnecting: "ยกเลิกการเชื่อมต่อ", connectCancelled: "ยกเลิกการเชื่อมต่อแล้ว", sshTotpCancelled: "ยกเลิกการป้อน Code แบบใช้ครั้งเดียวแล้ว", + httpTunnelProtocol: "โปรโตคอลทันเนล", + httpTunnelProtocolDbx: "สคริปต์ DBX TCP", + httpTunnelProtocolNavicat: "Navicat MySQL HTTP", + httpTunnelNavicatHint: "รองรับเฉพาะ MySQL; รองรับที่อยู่ HTTP/HTTPS แนะนำให้ใช้ HTTPS ไม่สามารถเปิดใช้งาน MySQL SSL หรือทันเนลอื่นพร้อมกันได้", + httpTunnelNavicatNoToken: "โปรโตคอล Navicat ไม่ใช้ Token", + httpTunnelNavicatScriptHelp: "ดูวิธีการรับสคริปต์ PHP อย่างเป็นทางการ", redisCaCert: "ใบรับรอง CA (ไม่บังคับ)", redisCaCertPlaceholder: "พาธของใบรับรอง CA ในรูปแบบ PEM", redisCaCertBrowse: "เลือกใบรับรอง CA ของ Redis", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 41bd1ee008..98c77428ec 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1960,7 +1960,13 @@ export default withEnglishFallback({ httpTunnel: "HTTP 隧道", httpTunnelAdd: "添加 HTTP 隧道", httpTunnelUrl: "隧道脚本 URL", + httpTunnelProtocol: "隧道协议", + httpTunnelProtocolDbx: "DBX TCP 脚本", + httpTunnelProtocolNavicat: "Navicat MySQL HTTP", + httpTunnelNavicatHint: "仅支持 MySQL。从 Navicat 安装目录获取 ntunnel_mysql.php,上传到 PHP 服务器后填写完整 URL。支持 HTTP/HTTPS;HTTP 会明文传输凭据和 SQL,仅用于可信内网。服务器若会重定向,请直接填写最终 HTTPS 地址。不支持同时启用 MySQL SSL 或其他隧道。", + httpTunnelNavicatScriptHelp: "查看官方 PHP 脚本获取方式", httpTunnelToken: "隧道 Token", + httpTunnelNavicatNoToken: "Navicat 协议不使用 Token", httpTunnelTokenPlaceholder: "dbx_tunnel.php 中配置的 token", httpTunnelConnectTimeout: "隧道超时(秒)", httpTunnelDefaultName: "HTTP 隧道 {index}", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 6a035d7920..50217daba0 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1827,6 +1827,12 @@ export default withEnglishFallback({ mqttClientKeyPath: "用戶端私鑰路徑", mqttNoLocal: "禁止本機轉送", readOnlyExplanation: "唯讀模式:直接阻止目前連線的寫入操作,需要暫時解鎖後才能寫入。", + httpTunnelProtocol: "隧道協定", + httpTunnelProtocolDbx: "DBX TCP 指令碼", + httpTunnelProtocolNavicat: "Navicat MySQL HTTP", + httpTunnelNavicatHint: "僅支援 MySQL;支援 HTTP/HTTPS 位址,建議使用 HTTPS。不能同時啟用 MySQL SSL 或其他隧道。", + httpTunnelNavicatNoToken: "Navicat 通訊協定不使用 Token", + httpTunnelNavicatScriptHelp: "查看官方 PHP 腳本取得方式", redisCaCert: "CA 憑證(選填)", redisCaCertPlaceholder: "PEM 格式 CA 憑證路徑", redisCaCertBrowse: "選擇 Redis CA 憑證", diff --git a/apps/desktop/src/lib/connection/tunnelProfiles.ts b/apps/desktop/src/lib/connection/tunnelProfiles.ts index fd5f00f8e7..2912d0d29b 100644 --- a/apps/desktop/src/lib/connection/tunnelProfiles.ts +++ b/apps/desktop/src/lib/connection/tunnelProfiles.ts @@ -48,6 +48,7 @@ export function createTunnelProfile(type: TunnelProfileType): TunnelProfile { enabled: true, url: "", token: "", + protocol: "dbx_tcp", connect_timeout_secs: 10, }; } diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 26f572008c..49950c431e 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -380,6 +380,7 @@ export interface HttpTunnelConfig { enabled?: boolean; url: string; token?: string; + protocol?: "dbx_tcp" | "navicat_mysql"; connect_timeout_secs?: number; /** See {@link SshTunnelConfig.profile_id}. */ profile_id?: string; diff --git a/crates/dbx-core/src/connection/connection_secrets.rs b/crates/dbx-core/src/connection/connection_secrets.rs index 39b1120a1d..5a4b13dc00 100644 --- a/crates/dbx-core/src/connection/connection_secrets.rs +++ b/crates/dbx-core/src/connection/connection_secrets.rs @@ -1199,6 +1199,7 @@ mod tests { enabled: true, url: "https://dbx.example.com/dbx_tunnel.php".to_string(), token: token.to_string(), + protocol: crate::models::connection::HttpTunnelProtocol::DbxTcp, connect_timeout_secs: 10, }) } diff --git a/crates/dbx-core/src/connection/mod.rs b/crates/dbx-core/src/connection/mod.rs index 54789e0653..390fb4bd59 100644 --- a/crates/dbx-core/src/connection/mod.rs +++ b/crates/dbx-core/src/connection/mod.rs @@ -3732,9 +3732,70 @@ impl AppState { config: &ConnectionConfig, ) -> Result { let transport_layers = self.resolved_transport_layers(config).await?; + let transport_summary = transport_layers + .iter() + .map(|layer| match layer { + TransportLayerConfig::Ssh(_) => "ssh".to_string(), + TransportLayerConfig::Proxy(_) => "proxy".to_string(), + TransportLayerConfig::HttpTunnel(http) => { + format!("http_tunnel:{:?}:{}", http.protocol, redact_transport_url(&http.url)) + } + }) + .collect::>() + .join(","); + log::info!( + "[transport] connection_id={} db_type={:?} layer_count={} layers=[{}] target={}:{}", + connection_id, + config.db_type, + transport_layers.len(), + transport_summary, + config.host, + config.port + ); + let navicat_layer = transport_layers.iter().enumerate().find_map(|(index, layer)| match layer { + TransportLayerConfig::HttpTunnel(http) + if http.protocol == crate::models::connection::HttpTunnelProtocol::NavicatMysql => + { + Some(index) + } + _ => None, + }); + if let Some(index) = navicat_layer { + if config.db_type != DatabaseType::Mysql { + return Err("Navicat HTTP tunnel protocol is only supported for MySQL connections.".to_string()); + } + if index != 0 || transport_layers.len() != 1 { + return Err("Navicat MySQL HTTP tunnel must be the only transport layer.".to_string()); + } + if config.ssl + || !config.ca_cert_path.is_empty() + || !config.client_cert_path.is_empty() + || !config.client_key_path.is_empty() + { + return Err("Navicat MySQL HTTP tunnel does not support MySQL TLS settings. Disable MySQL SSL or remove the TLS certificate paths.".to_string()); + } + } if transport_layers.is_empty() || db::sqlite_worker::sqlite_remote_worker_requested(config) { return Ok(ConnectionEndpoint::direct(config.host.clone(), config.port)); } + if let Some(TransportLayerConfig::HttpTunnel(http)) = transport_layers.first() { + if http.protocol == crate::models::connection::HttpTunnelProtocol::NavicatMysql { + let local_port = self + .http_tunnels + .start_navicat_mysql_tunnel( + connection_id, + &http.url, + http.connect_timeout_secs, + &config.host, + config.port, + &config.username, + &config.password, + config.database.as_deref(), + ) + .await?; + return Ok(ConnectionEndpoint::direct("127.0.0.1".to_string(), local_port)); + } + } if config.db_type == DatabaseType::ClickHouse || config.db_type == DatabaseType::CloudflareD1 || config.db_type == DatabaseType::Turso @@ -6406,6 +6467,18 @@ impl AppState { } } +fn redact_transport_url(raw: &str) -> String { + reqwest::Url::parse(raw) + .map(|mut url| { + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + url.to_string() + }) + .unwrap_or_else(|_| "".to_string()) +} + fn gaussdb_identifier_quote_from_query_result(result: &db::QueryResult) -> Option { let compatibility_mode = result.rows.first()?.first()?.as_str()?; db::postgres::gaussdb_identifier_quote_for_compatibility_mode(compatibility_mode).map(str::to_string) @@ -7603,12 +7676,12 @@ mod tests { gaussdb_identifier_quote_from_query_result, gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver, kafka_single_loopback_bootstrap_endpoint, keepalive_failure_proves_pool_dead, metadata_connection_config, metadata_pool_database, mysql_metadata_fallback_url, mysql_pool_setup_queries, oceanbase_mysql_setup_queries, - prestosql_jdbc_config_for_endpoint, redacted_connection_url_for_endpoint, redis_sentinel_transport_id, - redis_sentinel_transport_prefix, report_connection_liveness_lost, sqlserver_legacy_agent_config, - sqlserver_legacy_driver_error, sqlserver_uses_legacy_driver, task_client_session_id, - transport_layers_through_last_ssh, upsert_connection_url_param, uses_bare_mysql_pool, uses_tcp_probe, - validate_connection_url_params, validate_h2_database_path, AppState, MysqlMode, PoolKind, TxnConnection, - GAUSSDB_M_JDBC_DRIVER_CLASS, GAUSSDB_M_JDBC_DRIVER_PROFILE, PRESTOSQL_JDBC_DRIVER_CLASS, + prestosql_jdbc_config_for_endpoint, redact_transport_url, redacted_connection_url_for_endpoint, + redis_sentinel_transport_id, redis_sentinel_transport_prefix, report_connection_liveness_lost, + sqlserver_legacy_agent_config, sqlserver_legacy_driver_error, sqlserver_uses_legacy_driver, + task_client_session_id, transport_layers_through_last_ssh, upsert_connection_url_param, uses_bare_mysql_pool, + uses_tcp_probe, validate_connection_url_params, validate_h2_database_path, AppState, MysqlMode, PoolKind, + TxnConnection, GAUSSDB_M_JDBC_DRIVER_CLASS, GAUSSDB_M_JDBC_DRIVER_PROFILE, PRESTOSQL_JDBC_DRIVER_CLASS, }; use crate::agent_connection::{ agent_connect_params, mongo_legacy_error_with_auth_hint, mongo_uses_legacy_driver, @@ -7628,6 +7701,15 @@ mod tests { use std::sync::Arc; use std::time::{Duration, Instant}; + #[test] + fn transport_url_redaction_removes_credentials_query_and_fragment() { + assert_eq!( + redact_transport_url("https://alice:secret@example.test/tunnel.php?token=secret#fragment"), + "https://example.test/tunnel.php" + ); + assert_eq!(redact_transport_url("https://alice:secret@%/"), ""); + } + pub(super) fn mysql_config(database: Option<&str>) -> ConnectionConfig { ConnectionConfig { oracle_oci_nls_lang: None, @@ -11890,6 +11972,7 @@ for line in sys.stdin: enabled: true, url: String::new(), token: String::new(), + protocol: crate::models::connection::HttpTunnelProtocol::DbxTcp, connect_timeout_secs: 10, profile_id: profile_id.to_string(), } diff --git a/crates/dbx-core/src/persistence/cloud_sync.rs b/crates/dbx-core/src/persistence/cloud_sync.rs index 6140901547..4b1bbf6edf 100644 --- a/crates/dbx-core/src/persistence/cloud_sync.rs +++ b/crates/dbx-core/src/persistence/cloud_sync.rs @@ -4153,6 +4153,7 @@ mod tests { enabled: true, url: "https://dbx.example.com/dbx_tunnel.php".to_string(), token: "tunnel-token".to_string(), + protocol: crate::models::connection::HttpTunnelProtocol::DbxTcp, connect_timeout_secs: 10, }), ], diff --git a/crates/dbx-core/src/persistence/storage.rs b/crates/dbx-core/src/persistence/storage.rs index ac7b8b7961..a745033272 100644 --- a/crates/dbx-core/src/persistence/storage.rs +++ b/crates/dbx-core/src/persistence/storage.rs @@ -11761,6 +11761,7 @@ mod tests { enabled: true, url: "https://bastion.example.com".to_string(), token: "tunnel-token".to_string(), + protocol: crate::models::connection::HttpTunnelProtocol::DbxTcp, connect_timeout_secs: 5, profile_id: String::new(), }); diff --git a/crates/dbx-driver-mysql/src/mysql.rs b/crates/dbx-driver-mysql/src/mysql.rs index 3fe667ee96..594fa20c43 100644 --- a/crates/dbx-driver-mysql/src/mysql.rs +++ b/crates/dbx-driver-mysql/src/mysql.rs @@ -2718,6 +2718,7 @@ fn mysql_error_should_retry_with_text_protocol(error: &str) -> bool { || (lower.contains("1615") && lower.contains("re-prepared")) || lower.contains("com_stmt_prepare") || lower.contains("can't parse") + || lower.contains("1295") || lower.contains("buf doesn't have enough data") || lower.contains("prepared statement protocol") || lower.contains("this command is not supported in the prepared statement protocol yet") @@ -9556,6 +9557,13 @@ mod tests { assert!(mysql_error_should_retry_with_text_protocol(error)); } + #[test] + fn mysql_navicat_unsupported_prepare_error_can_retry_with_text_protocol() { + let error = "Server error: ERROR 1295 (HY000): Prepared statements are not supported by Navicat HTTP tunnels"; + + assert!(mysql_error_should_retry_with_text_protocol(error)); + } + #[test] fn mysql_reprepared_statement_error_can_retry_with_text_protocol() { let error = "Server error: ERROR HY000 (1615): Prepared statement needs to be re-prepared"; diff --git a/crates/dbx-driver-support/Cargo.toml b/crates/dbx-driver-support/Cargo.toml index e2c3bb2507..684111c18d 100644 --- a/crates/dbx-driver-support/Cargo.toml +++ b/crates/dbx-driver-support/Cargo.toml @@ -13,9 +13,11 @@ test-support = ["dbx-platform/test-support"] dbx-platform = { path = "../dbx-platform" } dbx-types = { path = "../dbx-types", default-features = false } bytes = "1" +base64 = "0.22" glob = "0.3" +log = "0.4" portpicker = "0.1.1" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "socks"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "charset", "socks"] } russh = "0.60" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["arbitrary_precision", "preserve_order"] } diff --git a/crates/dbx-driver-support/src/http_tunnel.rs b/crates/dbx-driver-support/src/http_tunnel.rs index 4e6132e8f0..f34193d02f 100644 --- a/crates/dbx-driver-support/src/http_tunnel.rs +++ b/crates/dbx-driver-support/src/http_tunnel.rs @@ -38,6 +38,15 @@ impl HttpTunnelManager { return Ok(local_port); } let local_port = portpicker::pick_unused_port().ok_or("No available port")?; + log::info!( + "[http-tunnel] starting dbx_tcp connection_id={} local_port={} url={} target={}:{} timeout_secs={}", + connection_id, + local_port, + redacted_tunnel_url(tunnel_url), + remote_host, + remote_port, + connect_timeout_secs + ); let listener = TcpListener::bind(("127.0.0.1", local_port)) .await .map_err(|e| format!("Failed to bind HTTP tunnel local port: {e}"))?; @@ -56,6 +65,52 @@ impl HttpTunnelManager { Ok(local_port) } + pub async fn start_navicat_mysql_tunnel( + &self, + connection_id: &str, + tunnel_url: &str, + connect_timeout_secs: u64, + remote_host: &str, + remote_port: u16, + username: &str, + password: &str, + database: Option<&str>, + ) -> 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")?; + log::info!( + "[http-tunnel] starting navicat_mysql connection_id={} local_port={} url={} target={}:{} timeout_secs={}", + connection_id, + local_port, + redacted_tunnel_url(tunnel_url), + remote_host, + remote_port, + connect_timeout_secs + ); + let listener = TcpListener::bind(("127.0.0.1", local_port)) + .await + .map_err(|error| format!("Failed to bind Navicat MySQL tunnel local port: {error}"))?; + let endpoint = crate::navicat_mysql_tunnel::NavicatMysqlEndpoint::new( + tunnel_url, + remote_host, + remote_port, + username, + password, + database, + connect_timeout_secs, + )?; + let handle = tokio::spawn(crate::navicat_mysql_tunnel::serve(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) } @@ -84,6 +139,18 @@ impl HttpTunnelManager { } } +fn redacted_tunnel_url(raw: &str) -> String { + reqwest::Url::parse(raw) + .map(|mut url| { + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + url.to_string() + }) + .unwrap_or_else(|_| "".to_string()) +} + struct HttpTunnelEndpoint { url: String, token: String, @@ -300,13 +367,22 @@ fn effective_connect_timeout_secs(value: u64) -> u64 { #[cfg(test)] mod tests { - use super::{script_url, validate_script_url, HttpTunnelManager}; + use super::{redacted_tunnel_url, script_url, validate_script_url, HttpTunnelManager}; use std::sync::Arc; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{Mutex, Notify}; use tokio::time::{timeout, Duration}; + #[test] + fn tunnel_log_url_removes_credentials_and_does_not_echo_invalid_input() { + assert_eq!( + redacted_tunnel_url("https://alice:secret@example.test/tunnel.php?token=secret#fragment"), + "https://example.test/tunnel.php" + ); + assert_eq!(redacted_tunnel_url("https://alice:secret@%/"), ""); + } + #[test] fn script_url_preserves_existing_query_and_appends_action() { let url = script_url( diff --git a/crates/dbx-driver-support/src/lib.rs b/crates/dbx-driver-support/src/lib.rs index ff6952a975..6dcb81a745 100644 --- a/crates/dbx-driver-support/src/lib.rs +++ b/crates/dbx-driver-support/src/lib.rs @@ -10,6 +10,7 @@ pub mod elasticsearch_sql; pub mod execution; pub mod file_validator; pub mod http_tunnel; +pub mod navicat_mysql_tunnel; pub mod query_messages; pub mod runtime_config; pub mod ssh_config; diff --git a/crates/dbx-driver-support/src/navicat_mysql_tunnel.rs b/crates/dbx-driver-support/src/navicat_mysql_tunnel.rs new file mode 100644 index 0000000000..632b1d0f5d --- /dev/null +++ b/crates/dbx-driver-support/src/navicat_mysql_tunnel.rs @@ -0,0 +1,760 @@ +use base64::Engine; +use reqwest::{Client, Url}; +use sqlparser::dialect::MySqlDialect; +use sqlparser::tokenizer::{Token, Tokenizer, Whitespace}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::task::JoinSet; +use tokio::time::{timeout, Duration}; + +const CLIENT_PROTOCOL_41: u32 = 0x0000_0200; +const CLIENT_SECURE_CONNECTION: u32 = 0x0000_8000; +const CLIENT_PLUGIN_AUTH: u32 = 0x0008_0000; +const SERVER_CAPABILITIES: u32 = CLIENT_PROTOCOL_41 | CLIENT_SECURE_CONNECTION | CLIENT_PLUGIN_AUTH; +const MAX_PACKET_SIZE: usize = 16 * 1024 * 1024; + +#[derive(Clone)] +pub struct NavicatMysqlEndpoint { + url: Url, + host: String, + port: u16, + username: String, + password: String, + database: String, + request_timeout: Duration, + client: Client, +} + +impl NavicatMysqlEndpoint { + pub fn new( + url: &str, + host: &str, + port: u16, + username: &str, + password: &str, + database: Option<&str>, + request_timeout_secs: u64, + ) -> Result { + let url = Url::parse(url.trim()).map_err(|error| format!("Invalid Navicat tunnel URL: {error}"))?; + match url.scheme() { + "https" => {} + "http" => log::warn!( + "[navicat-http] using unencrypted HTTP tunnel; database credentials and SQL are exposed in transit" + ), + other => return Err(format!("Unsupported Navicat tunnel URL scheme: {other}")), + } + if !url.path().to_ascii_lowercase().ends_with(".php") { + return Err( + "Navicat tunnel URL must point to the PHP tunnel script, for example https://example.com/ntunnel_mysql.php" + .to_string(), + ); + } + if host.trim().is_empty() || port == 0 { + return Err("Navicat tunnel requires a MySQL target host and port".to_string()); + } + if username.is_empty() { + return Err("Navicat tunnel requires a MySQL username".to_string()); + } + Ok(Self { + url, + host: host.to_string(), + port, + username: username.to_string(), + password: password.to_string(), + database: database.unwrap_or_default().to_string(), + request_timeout: Duration::from_secs(request_timeout_secs.clamp(1, 300)), + client: Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("Failed to create Navicat HTTP client: {error}"))?, + }) + } + + async fn query(&self, database: &str, sql: &str) -> Result, String> { + let started = std::time::Instant::now(); + let encoded_query = base64::engine::general_purpose::STANDARD.encode(sql.as_bytes()); + let fields = [ + ("actn", "Q".to_string()), + ("host", self.host.clone()), + ("port", self.port.to_string()), + ("login", self.username.clone()), + ("password", self.password.clone()), + ("db", database.to_string()), + ("encodeBase64", "1".to_string()), + ("q[]", encoded_query), + ]; + log::debug!( + "[navicat-http] POST tunnel request target={}:{} db={} sql_bytes={} timeout_secs={}", + self.host, + self.port, + if database.is_empty() { "" } else { "" }, + sql.len(), + self.request_timeout.as_secs() + ); + let (status, response_url, content_type, body) = timeout(self.request_timeout, async { + let response = self.client.post(self.url.clone()).form(&fields).send().await?; + let status = response.status(); + let mut response_url = response.url().clone(); + let _ = response_url.set_username(""); + let _ = response_url.set_password(None); + response_url.set_query(None); + response_url.set_fragment(None); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let body = response.bytes().await?; + Ok::<_, reqwest::Error>((status, response_url, content_type, body)) + }) + .await + .map_err(|_| { + log::warn!( + "[navicat-http] request timed out after {:?} target={}:{}", + started.elapsed(), + self.host, + self.port + ); + "Navicat HTTP tunnel request timed out".to_string() + })? + .map_err(|error| { + log::warn!( + "[navicat-http] request transport error after {:?} target={}:{}: {error}", + started.elapsed(), + self.host, + self.port + ); + format!("Navicat HTTP tunnel request failed: {error}") + })?; + log::debug!( + "[navicat-http] response status={} content_type={:?} final_url={} bytes={} elapsed={:?}", + status, + content_type, + response_url, + body.len(), + started.elapsed() + ); + if !status.is_success() { + let text = String::from_utf8_lossy(&body); + return Err(format!("Navicat HTTP tunnel returned HTTP {status}: {}", text.trim())); + } + if body.len() < 16 + || u32::from_be_bytes(body[0..4].try_into().unwrap()) != 1111 + || u16::from_be_bytes(body[4..6].try_into().unwrap()) != 204 + { + let header_bytes = body.iter().take(16).map(|byte| format!("{byte:02x}")).collect::>().join(" "); + let preview = String::from_utf8_lossy(body.get(..body.len().min(120)).unwrap_or_default()) + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .collect::(); + let message = format!( + "Invalid Navicat HTTP tunnel response: HTTP {status}, content-type={}, final_url={}, bytes={}, header=[{}], preview={:?}", + content_type.as_deref().unwrap_or(""), + response_url, + body.len(), + header_bytes, + preview.trim() + ); + log::warn!("[navicat-http] {message}"); + return Err(message); + } + Ok(body.to_vec()) + } +} + +pub async fn serve(listener: TcpListener, endpoint: NavicatMysqlEndpoint) { + let endpoint = Arc::new(endpoint); + let mut clients = JoinSet::new(); + loop { + tokio::select! { + accepted = listener.accept() => match accepted { + Ok((stream, _)) => { + let peer = stream.peer_addr().ok(); + let endpoint = endpoint.clone(); + clients.spawn(async move { + let mut stream = stream; + if let Err(error) = serve_client(&mut stream, endpoint).await { + log::warn!("[navicat-mysql] local client session failed peer={peer:?}: {error}"); + let _ = send_mysql_error(&mut stream, 1, 1105, &error).await; + } + }); + } + Err(_) => break, + }, + _ = clients.join_next(), if !clients.is_empty() => {} + } + } +} + +async fn serve_client(stream: &mut TcpStream, endpoint: Arc) -> Result<(), String> { + let peer = stream.peer_addr().ok(); + let seed_a = uuid::Uuid::new_v4(); + let seed_b = uuid::Uuid::new_v4(); + let mut auth_seed = [0; 20]; + auth_seed[..16].copy_from_slice(seed_a.as_bytes()); + auth_seed[16..].copy_from_slice(&seed_b.as_bytes()[..4]); + let greeting = mysql_handshake_greeting(&auth_seed); + write_packet(stream, 0, &greeting).await?; + log::debug!("[navicat-mysql] sent protocol greeting peer={peer:?} bytes={}", greeting.len()); + + let (sequence, handshake) = read_packet(stream).await.map_err(|error| { + log::warn!("[navicat-mysql] failed waiting for client handshake peer={peer:?}: {error}"); + error + })?; + log::debug!( + "[navicat-mysql] received client handshake peer={peer:?} sequence={sequence} bytes={} capabilities=0x{:08x}", + handshake.len(), + handshake.get(..4).and_then(|bytes| <[u8; 4]>::try_from(bytes).ok()).map(u32::from_le_bytes).unwrap_or(0) + ); + if handshake.len() < 32 { + return Err("Invalid MySQL handshake response".to_string()); + } + let client_capabilities = u32::from_le_bytes(handshake[0..4].try_into().unwrap()); + if client_capabilities & CLIENT_PROTOCOL_41 == 0 { + return send_mysql_error(stream, sequence.wrapping_add(1), 1043, "MySQL protocol 4.1 is required").await; + } + write_packet(stream, sequence.wrapping_add(1), &mysql_ok_packet(0, 0, "")).await?; + log::debug!("[navicat-mysql] authenticated local client peer={peer:?}"); + + let mut database = endpoint.database.clone(); + loop { + let (sequence, packet) = match read_packet(stream).await { + Ok(packet) => packet, + Err(error) if error == "MySQL client disconnected" => return Ok(()), + Err(error) => { + log::warn!("[navicat-mysql] failed reading client command peer={peer:?}: {error}"); + return Err(error); + } + }; + let Some((&command, payload)) = packet.split_first() else { + continue; + }; + log::debug!("[navicat-mysql] received command peer={peer:?} command=0x{command:02x} sequence={sequence} payload_bytes={}", payload.len()); + match command { + 0x01 => return Ok(()), + 0x0e => write_packet(stream, sequence.wrapping_add(1), &mysql_ok_packet(0, 0, "")).await?, + 0x02 => { + database = String::from_utf8_lossy(payload).to_string(); + write_packet(stream, sequence.wrapping_add(1), &mysql_ok_packet(0, 0, "")).await?; + } + 0x03 => { + let sql = String::from_utf8_lossy(payload).to_string(); + if let Err(error) = validate_stateless_query(&sql) { + send_mysql_error(stream, sequence.wrapping_add(1), 1235, &error).await?; + continue; + } + if let Some(next_database) = parse_use_database(&sql) { + database = next_database; + write_packet(stream, sequence.wrapping_add(1), &mysql_ok_packet(0, 0, "")).await?; + continue; + } + match endpoint.query(&database, &sql).await.and_then(|body| parse_navicat_response(&body)) { + Ok(result) => write_query_result(stream, result).await?, + Err(error) => send_mysql_error(stream, sequence.wrapping_add(1), 1105, &error).await?, + } + } + 0x16 => { + send_mysql_error( + stream, + sequence.wrapping_add(1), + 1295, + "Prepared statements are not supported by Navicat HTTP tunnels", + ) + .await? + } + _ => { + send_mysql_error(stream, sequence.wrapping_add(1), 1047, "Unsupported command for Navicat HTTP tunnel") + .await? + } + } + } +} + +fn validate_stateless_query(sql: &str) -> Result<(), String> { + let mut tokens = Vec::new(); + stateless_query_tokens(sql, &mut tokens)?; + for statement in tokens.split(|token| *token == Token::SemiColon) { + let word_is = |token: Option<&Token>, expected: &str| matches!(token, Some(Token::Word(word)) if word.value.eq_ignore_ascii_case(expected)); + let first = statement.first(); + let transaction = + ["BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "XA"].iter().any(|keyword| word_is(first, keyword)) + || (word_is(first, "START") && word_is(statement.get(1), "TRANSACTION")) + || (word_is(first, "RELEASE") && word_is(statement.get(1), "SAVEPOINT")) + || (word_is(first, "SET") + && statement.iter().any(|token| { + word_is(Some(token), "AUTOCOMMIT") + || word_is(Some(token), "@@AUTOCOMMIT") + || word_is(Some(token), "TRANSACTION") + })); + if transaction { + return Err( + "Transactions and autocommit settings are not supported by Navicat HTTP tunnels: each request uses a separate MySQL session" + .to_string(), + ); + } + } + Ok(()) +} + +fn stateless_query_tokens(sql: &str, output: &mut Vec) -> Result<(), String> { + for token in Tokenizer::new(&MySqlDialect {}, sql) + .tokenize() + .map_err(|error| format!("Cannot validate Navicat HTTP tunnel query: {error}"))? + { + match token { + Token::Whitespace(Whitespace::MultiLineComment(comment)) => { + // MySQL executes versioned comments; inspect their SQL as well. + if let Some(body) = comment.strip_prefix('!').or_else(|| comment.strip_prefix("M!")) { + stateless_query_tokens(body.trim_start_matches(|ch: char| ch.is_ascii_digit()), output)?; + } + } + Token::Whitespace(_) | Token::EOF => {} + token => output.push(token), + } + } + Ok(()) +} + +fn mysql_handshake_greeting(auth_seed: &[u8; 20]) -> Vec { + let mut greeting = vec![10]; + greeting.extend_from_slice(b"8.0.0-dbx-navicat\0"); + // MySQL's handshake places a 4-byte connection id before the first + // eight bytes of the authentication seed. Omitting it shifts every + // following field and makes clients wait for a handshake that never + // arrives. + greeting.extend_from_slice(&1u32.to_le_bytes()); + greeting.extend_from_slice(&auth_seed[..8]); + greeting.push(0); // auth-plugin-data-part-1 terminator + greeting.extend_from_slice(&(SERVER_CAPABILITIES as u16).to_le_bytes()); + greeting.push(45); + greeting.extend_from_slice(&2u16.to_le_bytes()); + greeting.extend_from_slice(&((SERVER_CAPABILITIES >> 16) as u16).to_le_bytes()); + greeting.push(21); + greeting.extend_from_slice(&[0; 10]); + greeting.extend_from_slice(&auth_seed[8..]); + greeting.push(0); + greeting.extend_from_slice(b"mysql_native_password\0"); + greeting +} + +#[derive(Debug, PartialEq, Eq)] +struct NavicatResult { + affected_rows: u64, + last_insert_id: u64, + columns: Vec, + rows: Vec>>>, + info: String, +} + +#[derive(Debug, PartialEq, Eq)] +struct NavicatColumn { + name: Vec, + table: Vec, + column_type: u8, + flags: u32, + length: u32, +} + +fn parse_navicat_response(data: &[u8]) -> Result { + if data.len() < 16 || u32::from_be_bytes(data[0..4].try_into().unwrap()) != 1111 { + return Err("Invalid Navicat HTTP tunnel response header".to_string()); + } + if u16::from_be_bytes(data[4..6].try_into().unwrap()) != 204 { + return Err("Unsupported Navicat HTTP tunnel response version".to_string()); + } + let connect_error = u32::from_be_bytes(data[6..10].try_into().unwrap()); + let mut cursor = 16; + if connect_error != 0 { + let message = read_navicat_block(data, &mut cursor)?; + return Err(format!("MySQL connection error {connect_error}: {}", String::from_utf8_lossy(&message))); + } + let mut result = NavicatResult { + affected_rows: 0, + last_insert_id: 0, + columns: Vec::new(), + rows: Vec::new(), + info: String::new(), + }; + if cursor == data.len() { + return Ok(result); + } + // Navicat's result-set header has five 4-byte integers followed by + // twelve reserved bytes: 32 bytes total. + if data.len() - cursor < 32 { + return Err("Truncated Navicat query result header".to_string()); + } + let query_error = u32::from_be_bytes(data[cursor..cursor + 4].try_into().unwrap()); + result.affected_rows = u32::from_be_bytes(data[cursor + 4..cursor + 8].try_into().unwrap()) as u64; + result.last_insert_id = u32::from_be_bytes(data[cursor + 8..cursor + 12].try_into().unwrap()) as u64; + let column_count = u32::from_be_bytes(data[cursor + 12..cursor + 16].try_into().unwrap()) as usize; + let row_count = u32::from_be_bytes(data[cursor + 16..cursor + 20].try_into().unwrap()) as usize; + cursor += 32; + if query_error != 0 { + let message = read_navicat_block(data, &mut cursor)?; + return Err(format!("MySQL query error {query_error}: {}", String::from_utf8_lossy(&message))); + } + for _ in 0..column_count { + let name = read_navicat_block(data, &mut cursor)?; + let table = read_navicat_block(data, &mut cursor)?; + let column_type = read_u32_be(data, &mut cursor)? as u8; + let flags = read_u32_be(data, &mut cursor)?; + let length = read_u32_be(data, &mut cursor)?; + result.columns.push(NavicatColumn { name, table, column_type, flags, length }); + } + for _ in 0..row_count { + let mut row = Vec::with_capacity(column_count); + for _ in 0..column_count { + if data.get(cursor) == Some(&0xff) { + cursor += 1; + row.push(None); + } else { + row.push(Some(read_navicat_block(data, &mut cursor)?)); + } + } + result.rows.push(row); + } + if result.columns.is_empty() { + result.info = String::from_utf8_lossy(&read_navicat_block(data, &mut cursor)?).to_string(); + } + if data.get(cursor) != Some(&0) || cursor + 1 != data.len() { + return Err("Invalid or missing Navicat result terminator".to_string()); + } + Ok(result) +} + +fn read_navicat_block(data: &[u8], cursor: &mut usize) -> Result, String> { + let marker = *data.get(*cursor).ok_or("Truncated Navicat value")?; + *cursor += 1; + let length = match marker { + 0..=253 => marker as usize, + 254 => read_u32_be(data, cursor)? as usize, + 255 => return Err("Unexpected NULL marker in Navicat value".to_string()), + }; + let end = cursor.checked_add(length).ok_or("Navicat value length overflow")?; + let value = data.get(*cursor..end).ok_or("Truncated Navicat value")?.to_vec(); + *cursor = end; + Ok(value) +} + +fn read_u32_be(data: &[u8], cursor: &mut usize) -> Result { + let end = cursor.checked_add(4).ok_or("Navicat response offset overflow")?; + let bytes: [u8; 4] = data.get(*cursor..end).ok_or("Truncated Navicat integer")?.try_into().unwrap(); + *cursor = end; + Ok(u32::from_be_bytes(bytes)) +} + +fn parse_use_database(sql: &str) -> Option { + let trimmed = sql.trim().trim_end_matches(';').trim(); + let rest = trimmed.get(..4)?.eq_ignore_ascii_case("USE ").then(|| trimmed[4..].trim())?; + let rest = rest.strip_prefix('`').and_then(|value| value.strip_suffix('`')).unwrap_or(rest); + (!rest.is_empty()).then(|| rest.replace("``", "`")) +} + +async fn write_query_result(stream: &mut TcpStream, result: NavicatResult) -> Result<(), String> { + let mut sequence = 1u8; + if result.columns.is_empty() { + write_packet(stream, sequence, &mysql_ok_packet(result.affected_rows, result.last_insert_id, &result.info)) + .await?; + return Ok(()); + } + let mut header = Vec::new(); + push_lenenc(&mut header, result.columns.len() as u64); + write_packet(stream, sequence, &header).await?; + sequence = sequence.wrapping_add(1); + for column in &result.columns { + let definition = mysql_column_packet(column); + write_packet(stream, sequence, &definition).await?; + sequence = sequence.wrapping_add(1); + } + write_packet(stream, sequence, &[0xfe, 0, 0, 2, 0]).await?; + sequence = sequence.wrapping_add(1); + for row in result.rows { + let mut payload = Vec::new(); + for value in row { + match value { + Some(value) => push_lenenc_bytes(&mut payload, &value), + None => payload.push(0xfb), + } + } + write_packet(stream, sequence, &payload).await?; + sequence = sequence.wrapping_add(1); + } + write_packet(stream, sequence, &[0xfe, 0, 0, 2, 0]).await +} + +fn mysql_column_packet(column: &NavicatColumn) -> Vec { + let mut packet = Vec::new(); + for value in [ + b"def".as_slice(), + b"".as_slice(), + column.table.as_slice(), + column.table.as_slice(), + column.name.as_slice(), + column.name.as_slice(), + ] { + push_lenenc_bytes(&mut packet, value); + } + packet.push(0x0c); + packet.extend_from_slice(&45u16.to_le_bytes()); + packet.extend_from_slice(&column.length.to_le_bytes()); + packet.push(column.column_type); + packet.extend_from_slice(&(column.flags as u16).to_le_bytes()); + packet.push(0); + packet.extend_from_slice(&[0, 0]); + packet +} + +fn mysql_ok_packet(affected_rows: u64, last_insert_id: u64, info: &str) -> Vec { + let mut packet = vec![0]; + push_lenenc(&mut packet, affected_rows); + push_lenenc(&mut packet, last_insert_id); + packet.extend_from_slice(&2u16.to_le_bytes()); + packet.extend_from_slice(&0u16.to_le_bytes()); + packet.extend_from_slice(info.as_bytes()); + packet +} + +async fn send_mysql_error(stream: &mut TcpStream, sequence: u8, code: u16, message: &str) -> Result<(), String> { + let mut payload = vec![0xff]; + payload.extend_from_slice(&code.to_le_bytes()); + payload.extend_from_slice(b"#HY000"); + payload.extend_from_slice(message.as_bytes()); + write_packet(stream, sequence, &payload).await +} + +async fn read_packet(stream: &mut TcpStream) -> Result<(u8, Vec), String> { + let mut header = [0; 4]; + match stream.read_exact(&mut header).await { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => { + return Err("MySQL client disconnected".to_string()) + } + Err(error) => return Err(format!("Failed to read MySQL packet header: {error}")), + } + let length = header[0] as usize | ((header[1] as usize) << 8) | ((header[2] as usize) << 16); + if length > MAX_PACKET_SIZE { + return Err("MySQL packet exceeds the supported size".to_string()); + } + let mut payload = vec![0; length]; + stream.read_exact(&mut payload).await.map_err(|error| format!("Failed to read MySQL packet: {error}"))?; + Ok((header[3], payload)) +} + +async fn write_packet(stream: &mut TcpStream, sequence: u8, payload: &[u8]) -> Result<(), String> { + if payload.len() > 0x00ff_ffff { + return Err("MySQL response packet exceeds 16 MiB".to_string()); + } + let length = payload.len(); + let header = [length as u8, (length >> 8) as u8, (length >> 16) as u8, sequence]; + stream.write_all(&header).await.map_err(|error| format!("Failed to write MySQL packet header: {error}"))?; + stream.write_all(payload).await.map_err(|error| format!("Failed to write MySQL packet: {error}")) +} + +fn push_lenenc(output: &mut Vec, value: u64) { + match value { + 0..=250 => output.push(value as u8), + 251..=0xffff => { + output.push(0xfc); + output.extend_from_slice(&(value as u16).to_le_bytes()); + } + 0x1_0000..=0xff_ffff => { + output.push(0xfd); + output.extend_from_slice(&(value as u32).to_le_bytes()[..3]); + } + _ => { + output.push(0xfe); + output.extend_from_slice(&value.to_le_bytes()); + } + } +} + +fn push_lenenc_bytes(output: &mut Vec, value: &[u8]) { + push_lenenc(output, value.len() as u64); + output.extend_from_slice(value); +} + +#[cfg(test)] +mod tests { + use super::{ + parse_navicat_response, parse_use_database, push_lenenc_bytes, read_packet, validate_stateless_query, + write_packet, NavicatMysqlEndpoint, CLIENT_PROTOCOL_41, CLIENT_SECURE_CONNECTION, + }; + use tokio::net::{TcpListener, TcpStream}; + + #[test] + fn accepts_http_and_https_tunnel_urls_without_changing_scheme() { + for scheme in ["http", "https"] { + let endpoint = NavicatMysqlEndpoint::new( + &format!("{scheme}://example.test/ntunnel_mysql.php"), + "127.0.0.1", + 3306, + "user", + "password", + None, + 10, + ) + .unwrap(); + + assert_eq!(endpoint.url.scheme(), scheme); + } + } + + #[test] + fn parses_navicat_query_response() { + let mut body = Vec::from([0, 0, 4, 87, 0, 204, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + body.extend_from_slice(&[0; 4]); + body.extend_from_slice(&[0, 0, 0, 1]); + body.extend_from_slice(&[0; 4]); + body.extend_from_slice(&[0, 0, 0, 1]); + body.extend_from_slice(&[0, 0, 0, 1]); + body.extend_from_slice(&[0; 12]); + push_lenenc_bytes(&mut body, b"id"); + push_lenenc_bytes(&mut body, b"users"); + body.extend_from_slice(&3u32.to_be_bytes()); + body.extend_from_slice(&2u32.to_be_bytes()); + body.extend_from_slice(&11u32.to_be_bytes()); + push_lenenc_bytes(&mut body, b"42"); + body.push(0); + + let result = parse_navicat_response(&body).unwrap(); + assert_eq!(result.columns.len(), 1); + assert_eq!(result.columns[0].name, b"id"); + assert_eq!(result.rows, vec![vec![Some(b"42".to_vec())]]); + } + + #[test] + fn parses_use_database_statement() { + assert_eq!(parse_use_database("USE `other``db`;"), Some("other`db".to_string())); + assert_eq!(parse_use_database("SELECT 1"), None); + } + + #[test] + fn rejects_transaction_control_including_comments_and_autocommit() { + for sql in [ + "BEGIN", + "START /* comment */ TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY", + "COMMIT", + "ROLLBACK TO SAVEPOINT s", + "SAVEPOINT s", + "RELEASE SAVEPOINT s", + "XA START 'x'", + "SET autocommit = 0", + "SET @@autocommit = 0", + "SET @@`autocommit` = 0", + "SET @@session.autocommit = OFF", + "SET SESSION `autocommit` = 0", + "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ", + "/*!40101 SET AUTOCOMMIT=0 */", + "/*M! SET autocommit=0 */", + "START /*!40100 TRANSACTION */", + "SELECT 1; BEGIN", + ] { + assert!(validate_stateless_query(sql).is_err(), "{sql}"); + } + for sql in [ + "SELECT 'BEGIN; SET autocommit=0'", + "SELECT `autocommit` FROM t", + "/* BEGIN */ SELECT 1", + "SET NAMES utf8mb4", + "SET SESSION group_concat_max_len = 1048576", + "SET @note = 'autocommit'", + "USE information_schema", + ] { + assert!(validate_stateless_query(sql).is_ok(), "{sql}"); + } + } + + #[tokio::test] + async fn transaction_commands_fail_before_any_http_request() { + let http_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/ntunnel_mysql.php", http_listener.local_addr().unwrap()); + let endpoint = NavicatMysqlEndpoint::new(&url, "localhost", 3306, "user", "password", None, 1).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut client = TcpStream::connect(listener.local_addr().unwrap()).await.unwrap(); + let server = tokio::spawn(super::serve(listener, endpoint)); + + read_packet(&mut client).await.unwrap(); + let mut handshake = [0; 32]; + handshake[..4].copy_from_slice(&CLIENT_PROTOCOL_41.to_le_bytes()); + write_packet(&mut client, 1, &handshake).await.unwrap(); + assert_eq!(read_packet(&mut client).await.unwrap().1[0], 0); + + for sql in ["START TRANSACTION", "BEGIN", "ROLLBACK", "COMMIT", "SET autocommit=0", "SET @@autocommit=0"] { + let response = send_test_command(&mut client, 0x03, sql.as_bytes()).await; + assert_eq!(response[0], 0xff, "{sql}"); + assert_eq!(u16::from_le_bytes(response[1..3].try_into().unwrap()), 1235, "{sql}"); + assert!(String::from_utf8_lossy(&response[9..]).contains("separate MySQL session")); + } + assert!(tokio::time::timeout(std::time::Duration::from_millis(50), http_listener.accept()).await.is_err()); + assert_eq!(send_test_command(&mut client, 0x0e, b"").await[0], 0); + server.abort(); + } + + #[tokio::test] + async fn live_navicat_http_tunnel_query_use_ping_and_error_paths() { + let (Some(url), Some(username), Some(password)) = ( + std::env::var("DBX_NAVICAT_TEST_URL").ok(), + std::env::var("DBX_NAVICAT_TEST_USERNAME").ok(), + std::env::var("DBX_NAVICAT_TEST_PASSWORD").ok(), + ) else { + eprintln!("skipping live Navicat tunnel test: DBX_NAVICAT_TEST_* variables are not all set"); + return; + }; + + let endpoint = NavicatMysqlEndpoint::new(&url, "localhost", 3306, &username, &password, None, 20).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(super::serve(listener, endpoint)); + let mut client = TcpStream::connect(address).await.unwrap(); + + let (_, greeting) = read_packet(&mut client).await.unwrap(); + assert_eq!(greeting.first(), Some(&10), "expected MySQL protocol 10 greeting"); + + let mut handshake = Vec::new(); + handshake.extend_from_slice(&(CLIENT_PROTOCOL_41 | CLIENT_SECURE_CONNECTION).to_le_bytes()); + handshake.extend_from_slice(&0x0100_0000u32.to_le_bytes()); + handshake.push(45); + handshake.extend_from_slice(&[0; 23]); + handshake.extend_from_slice(b"dbx-live-test\0"); + handshake.push(0); + write_packet(&mut client, 1, &handshake).await.unwrap(); + let (_, auth_result) = read_packet(&mut client).await.unwrap(); + assert_eq!(auth_result.first(), Some(&0), "expected local proxy authentication OK"); + + let use_result = send_test_command(&mut client, 0x03, b"USE information_schema").await; + assert_eq!(use_result.first(), Some(&0), "USE should return an OK packet"); + + let ping_result = send_test_command(&mut client, 0x0e, b"").await; + assert_eq!(ping_result.first(), Some(&0), "COM_PING should return an OK packet"); + + let _ = send_test_command(&mut client, 0x03, b"SELECT DATABASE()").await; + let (_, column_definition) = read_packet(&mut client).await.unwrap(); + assert!(!column_definition.is_empty()); + let (_, column_terminator) = read_packet(&mut client).await.unwrap(); + assert_eq!(column_terminator.first(), Some(&0xfe)); + let (_, row) = read_packet(&mut client).await.unwrap(); + assert_eq!(row.first().copied(), Some("information_schema".len() as u8)); + assert_eq!(&row[1..], b"information_schema", "USE must select the database for the following HTTP query"); + let (_, row_terminator) = read_packet(&mut client).await.unwrap(); + assert_eq!(row_terminator.first(), Some(&0xfe)); + + let sql_error = send_test_command(&mut client, 0x03, b"SELECT FROM dbx_live_test").await; + assert_eq!(sql_error.first(), Some(&0xff), "invalid SQL should return an ERR packet"); + assert_eq!(u16::from_le_bytes(sql_error[1..3].try_into().unwrap()), 1105); + assert!( + String::from_utf8_lossy(&sql_error[9..]).contains("1064"), + "the Navicat server error should be preserved" + ); + + write_packet(&mut client, 0, &[0x01]).await.unwrap(); + server.abort(); + } + + async fn send_test_command(client: &mut TcpStream, command: u8, payload: &[u8]) -> Vec { + let mut packet = vec![command]; + packet.extend_from_slice(payload); + write_packet(client, 0, &packet).await.unwrap(); + read_packet(client).await.unwrap().1 + } +} diff --git a/crates/dbx-drivers/src/db/transport_layer_tunnel.rs b/crates/dbx-drivers/src/db/transport_layer_tunnel.rs index 97a44d7d6d..7c93d0f453 100644 --- a/crates/dbx-drivers/src/db/transport_layer_tunnel.rs +++ b/crates/dbx-drivers/src/db/transport_layer_tunnel.rs @@ -440,6 +440,7 @@ mod tests { enabled: true, url: url.to_string(), token: String::new(), + protocol: dbx_types::models::connection::HttpTunnelProtocol::DbxTcp, connect_timeout_secs: 10, }) } diff --git a/crates/dbx-types/src/models/connection.rs b/crates/dbx-types/src/models/connection.rs index 89bcece7c5..6006f718cb 100644 --- a/crates/dbx-types/src/models/connection.rs +++ b/crates/dbx-types/src/models/connection.rs @@ -572,6 +572,8 @@ pub struct HttpTunnelConfig { pub url: String, #[serde(default)] pub token: String, + #[serde(default)] + pub protocol: HttpTunnelProtocol, #[serde(default = "default_http_tunnel_connect_timeout_secs")] pub connect_timeout_secs: u64, /// See [`SshTunnelConfig::profile_id`]. @@ -579,6 +581,14 @@ pub struct HttpTunnelConfig { pub profile_id: String, } +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HttpTunnelProtocol { + #[default] + DbxTcp, + NavicatMysql, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AttachedDatabaseConfig { pub name: String, diff --git a/deploy/NAVICAT_HTTP_TUNNEL.md b/deploy/NAVICAT_HTTP_TUNNEL.md new file mode 100644 index 0000000000..38ae6c335c --- /dev/null +++ b/deploy/NAVICAT_HTTP_TUNNEL.md @@ -0,0 +1,28 @@ +# Navicat MySQL HTTP Tunnel + +DBX supports Navicat's MySQL HTTP tunnel protocol. DBX does not include or +modify PremiumSoft's proprietary `ntunnel_mysql.php` script. Navicat documents +that the original script is supplied in its installation folder/package on +Windows and can be exported from the HTTP tab on macOS and Linux: +https://help.navicat.com/hc/en-us/articles/218283457-Where-can-I-get-the-HTTP-tunnel-script-file + +The Navicat EULA restricts copying, modification, and distribution except as +expressly allowed by its terms. Obtain the script from an authorized Navicat +distribution and follow the EULA for your edition: +https://www.navicat.com/manual/pdf_manual/en/navicat_17/win_manual/navicat_en.pdf + +The separate `deploy/dbx_tunnel.php` file is DBX's own tunnel and is not a +Navicat-compatible endpoint. + +Upload your authorized copy of `ntunnel_mysql.php` to the PHP host and configure +DBX with its full URL, such as `https://db.example.com/ntunnel_mysql.php`. Plain +HTTP is accepted for trusted private networks, but it exposes database +credentials and SQL in transit. If the endpoint redirects HTTP to HTTPS, +configure DBX with the final HTTPS URL; redirects are not followed because they +can change a POST request into a GET. + +This protocol supports regular queries, `USE`, ping, and disconnect. MySQL TLS +and prepared statements are not supported by the Navicat HTTP tunnel protocol. +Each HTTP request opens a separate MySQL session. Transactions (including manual +transactions and consistent backup snapshots), savepoints, and autocommit +settings are rejected before any SQL is sent to the PHP host. diff --git a/deploy/dbx_tunnel.php b/deploy/dbx_tunnel.php index 69706e7460..1b95e8dc78 100644 --- a/deploy/dbx_tunnel.php +++ b/deploy/dbx_tunnel.php @@ -65,6 +65,11 @@ try { function handle_open(): void { + $workerStartMethod = worker_start_method(); + if ($workerStartMethod === null) { + respond_error(503, 'HTTP tunnel is unsupported on this PHP host: it requires PHP-FPM fastcgi_finish_request() or permission to spawn PHP CLI with popen()'); + } + $session = request_session(); $host = request_param('dbx_target_host'); $port = (int) request_param('dbx_target_port'); @@ -83,7 +88,7 @@ function handle_open(): void touch($dir . DIRECTORY_SEPARATOR . 'in.queue'); touch($dir . DIRECTORY_SEPARATOR . 'out.queue'); - if (function_exists('fastcgi_finish_request')) { + if ($workerStartMethod === 'fastcgi') { respond_json(['ok' => true], false); fastcgi_finish_request(); run_worker($dir, $host, $port, $connectTimeout); @@ -97,7 +102,23 @@ function handle_open(): void 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'); + respond_error(503, 'HTTP tunnel worker could not be started with popen()'); +} + +function worker_start_method(): ?string +{ + return select_worker_start_method(function_exists('fastcgi_finish_request'), function_exists('popen')); +} + +function select_worker_start_method(bool $hasFastcgiFinishRequest, bool $hasPopen): ?string +{ + if ($hasFastcgiFinishRequest) { + return 'fastcgi'; + } + if ($hasPopen) { + return 'popen'; + } + return null; } function handle_write(): void diff --git a/deploy/tests/http_tunnel_worker_test.php b/deploy/tests/http_tunnel_worker_test.php index a273932a48..9711530e25 100644 --- a/deploy/tests/http_tunnel_worker_test.php +++ b/deploy/tests/http_tunnel_worker_test.php @@ -4,6 +4,9 @@ declare(strict_types=1); define('DBX_TUNNEL_FUNCTIONS_ONLY', true); require dirname(__DIR__) . DIRECTORY_SEPARATOR . 'dbx_tunnel.php'; +assert_same('fastcgi', select_worker_start_method(true, false), 'PHP-FPM can keep the request worker alive'); +assert_same('popen', select_worker_start_method(false, true), 'PHP CLI can be spawned when PHP-FPM is unavailable'); +assert_same(null, select_worker_start_method(false, false), 'CGI/FastCGI without process spawning cannot run the tunnel worker'); assert_same(10000, worker_poll_timeout_us(0), 'active polling starts at 10ms'); assert_same(10000, worker_poll_timeout_us(99), 'active polling lasts for 100 idle polls'); assert_same(50000, worker_poll_timeout_us(100), 'brief inactivity backs off to 50ms'); diff --git a/docs/content/docs/ssh-tunnel.cn.mdx b/docs/content/docs/ssh-tunnel.cn.mdx index f53b0e968d..d8e117e35c 100644 --- a/docs/content/docs/ssh-tunnel.cn.mdx +++ b/docs/content/docs/ssh-tunnel.cn.mdx @@ -102,6 +102,8 @@ DBX 支持: 当数据库在内网中,外部只能访问一台内网 Web 服务器,并且不能使用 SSH/VPN 时,可以使用 HTTP 隧道。这类场景和 Navicat、SQL-Front 的 HTTP 隧道类似。 +PHP 脚本需要 PHP-FPM 的 `fastcgi_finish_request()`,或允许通过 `popen()` 启动 PHP CLI。若 CGI/FastCGI 共享主机不具备这两种能力,就无法运行此 TCP 隧道;脚本会返回 HTTP 503 和具体原因。目前没有提供 SQL 级 MySQL HTTP 适配器。 + ### 部署脚本 把 `deploy/dbx_tunnel.php` 上传到一台能访问目标数据库的 PHP Web 服务器。 @@ -114,7 +116,7 @@ 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_TUNNEL_PHP` | 否 | PHP-FPM 没有 `fastcgi_finish_request()` 且 `popen()` 可用时,用于启动 worker 的 PHP CLI 路径。 | 在 DBX 里添加 **HTTP 隧道** 层,并填写: diff --git a/docs/content/docs/ssh-tunnel.mdx b/docs/content/docs/ssh-tunnel.mdx index a0fd778799..9440c35688 100644 --- a/docs/content/docs/ssh-tunnel.mdx +++ b/docs/content/docs/ssh-tunnel.mdx @@ -102,6 +102,8 @@ A shared proxy profile may include an optional test target such as `mysql.intern 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. +The PHP script needs either PHP-FPM's `fastcgi_finish_request()` or permission to spawn PHP CLI with `popen()`. CGI/FastCGI shared hosting with both capabilities unavailable cannot run this TCP tunnel; the script returns HTTP 503 with the reason. A SQL-level MySQL HTTP adapter is not provided. + ### Deploy The Script Upload `deploy/dbx_tunnel.php` to a PHP web server that can reach the database. @@ -114,7 +116,7 @@ Configure these environment variables on the PHP server: | `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. | +| `DBX_TUNNEL_PHP` | No | PHP CLI path used when PHP-FPM `fastcgi_finish_request()` is not available and `popen()` is allowed. | In DBX, add an **HTTP Tunnel** layer and fill: