feat(mysql): support Navicat HTTP tunnels on CGI/FastCGI hosts (#12494)

Add the Navicat MySQL HTTP transport for CGI/FastCGI hosts, including protocol selection, response translation, and prepared-query fallback. Keep the proprietary PHP script outside the repository and document authorized setup.

Reject transaction control and autocommit changes before sending HTTP requests, preserve plain HTTP URLs in both tunnel editors, and redact credentials from tunnel startup logs. Detect unsupported DBX PHP worker runtimes before opening a tunnel.

Validation: driver-support tests and Clippy, connection UI tests, frontend type checking and formatting, protocol-level transaction rejection and URL regression checks, and PR CI.

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
Eliasandzipg authored and GitHub committed 2026-10-20 18:42:55 +08:00
1 parent a544218dd6
commit e5d3ec7908
36 files changed
+1325 -90

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Generated
+2
View File
@@ -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",
+10
View File
@@ -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",
@@ -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>): 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) {
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.rocketmqConnectionMode") }}</Label>
<Select v-model="mqRocketmqConnectionMode">
<SelectTrigger class="col-span-3"><SelectValue /></SelectTrigger>
<SelectTrigger class="col-span-3 h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="remoting">Remoting</SelectItem>
<SelectItem value="proxy">Proxy (gRPC)</SelectItem>
@@ -7956,7 +7984,7 @@ function openExternalUrl(url: string) {
<div v-if="mqSystemKind === 'kafka'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqSaslMechanism") }}</Label>
<Select v-model="mqKafkaSaslMechanism">
<SelectTrigger class="col-span-3 h-9">
<SelectTrigger class="col-span-3 h-9 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -10645,10 +10673,10 @@ function openExternalUrl(url: string) {
</TabsContent>
<TabsContent v-if="canUseTransportLayers" value="transport" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2 pb-6">
<div class="connection-label-wide-grid grid min-w-0 grid-cols-4 items-start gap-4">
<div class="connection-form-body connection-transport-form-body grid min-h-0 w-full flex-1 scroll-pb-6 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2 pb-6">
<div class="connection-label-wide-grid grid w-full min-w-0 grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.sshHops") }}</Label>
<div class="col-span-3 grid min-w-0 gap-3">
<div class="col-span-3 grid w-full min-w-0 gap-3">
<div class="flex min-w-0 flex-wrap items-center gap-1 text-[11px] text-muted-foreground">
<template v-for="(segment, index) in transportPathSegments" :key="`${segment}-${index}`">
<span class="inline-block max-w-full truncate rounded border bg-muted/40 px-2 py-1">{{ segment }}</span>
@@ -10661,7 +10689,7 @@ function openExternalUrl(url: string) {
:key="hop.id"
type="button"
draggable="true"
class="connection-transport-layer-option flex min-h-10 items-center gap-2 rounded-md border px-2 text-left text-xs transition-colors"
class="connection-transport-layer-option flex min-h-10 w-full min-w-0 items-center gap-2 rounded-md border px-2 text-left text-xs transition-colors"
:class="hop.id === selectedTransportLayer?.id ? 'connection-transport-layer-option--selected border-primary bg-primary/5' : 'hover:bg-muted/50'"
@click="selectedTransportLayerId = hop.id"
@dragstart="draggedTransportLayerId = hop.id"
@@ -10750,18 +10778,20 @@ function openExternalUrl(url: string) {
<span v-else class="text-red-500">{{ t("connection.tunnelProfileMissing") }}</span>
</div>
</div>
<div v-if="!selectedLayerProfileId" class="grid grid-cols-4 items-center gap-4">
<div v-if="!selectedLayerProfileId" class="grid min-w-0 grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">Type</Label>
<Select :model-value="selectedTransportLayer.type" @update:model-value="(value: any) => changeSelectedTransportLayerType(value)">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ssh">SSH</SelectItem>
<SelectItem value="proxy">Proxy</SelectItem>
<SelectItem v-if="!sqliteRemoteTransportRestricted" value="http_tunnel">{{ t("connection.httpTunnel") }}</SelectItem>
</SelectContent>
</Select>
<div class="col-span-3 min-w-0">
<Select :model-value="selectedTransportLayer.type" @update:model-value="(value: any) => changeSelectedTransportLayerType(value)">
<SelectTrigger class="h-9 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ssh">SSH</SelectItem>
<SelectItem value="proxy">Proxy</SelectItem>
<SelectItem v-if="!sqliteRemoteTransportRestricted" value="http_tunnel">{{ t("connection.httpTunnel") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<template v-if="selectedSshLayer && !selectedLayerProfileId">
<div class="grid grid-cols-4 items-center gap-4">
@@ -10776,20 +10806,22 @@ function openExternalUrl(url: string) {
<Label :class="connectionLabelSmallClass">{{ t("connection.sshUser") }}</Label>
<Input v-model="selectedSshLayer.user" class="col-span-3" placeholder="root" :disabled="selectedSshLayer.enabled === false" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div class="grid min-w-0 grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshAuthMethod") }}</Label>
<Select :model-value="selectedSshLayer.auth_method || 'password'" :disabled="selectedSshLayer.enabled === false" @update:model-value="updateSelectedSshAuthMethod">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="password">{{ t("connection.sshAuthMethodPassword") }}</SelectItem>
<SelectItem value="key">{{ t("connection.sshAuthMethodKey") }}</SelectItem>
<SelectItem value="key+password">{{ t("connection.sshAuthMethodKeyPassword") }}</SelectItem>
<SelectItem value="agent">{{ t("connection.sshUseAgent") }}</SelectItem>
<SelectItem value="none">{{ t("connection.sshAuthMethodNone") }}</SelectItem>
</SelectContent>
</Select>
<div class="col-span-3 min-w-0">
<Select :model-value="selectedSshLayer.auth_method || 'password'" :disabled="selectedSshLayer.enabled === false" @update:model-value="updateSelectedSshAuthMethod">
<SelectTrigger class="h-9 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="password">{{ t("connection.sshAuthMethodPassword") }}</SelectItem>
<SelectItem value="key">{{ t("connection.sshAuthMethodKey") }}</SelectItem>
<SelectItem value="key+password">{{ t("connection.sshAuthMethodKeyPassword") }}</SelectItem>
<SelectItem value="agent">{{ t("connection.sshUseAgent") }}</SelectItem>
<SelectItem value="none">{{ t("connection.sshAuthMethodNone") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div v-if="selectedSshLayer.auth_method === 'key' || selectedSshLayer.auth_method === 'key+password'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshKeyPath") }}</Label>
@@ -10848,17 +10880,19 @@ function openExternalUrl(url: string) {
</div>
</template>
<template v-else-if="selectedProxyLayer && !selectedLayerProfileId">
<div class="grid grid-cols-4 items-center gap-4">
<div class="grid min-w-0 grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.proxyType") }}</Label>
<Select :model-value="selectedProxyLayer.proxy_type || 'socks5'" :disabled="selectedProxyLayer.enabled === false" @update:model-value="updateSelectedProxyType">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="socks5">SOCKS5</SelectItem>
<SelectItem value="http">HTTP CONNECT</SelectItem>
</SelectContent>
</Select>
<div class="col-span-3 min-w-0">
<Select :model-value="selectedProxyLayer.proxy_type || 'socks5'" :disabled="selectedProxyLayer.enabled === false" @update:model-value="updateSelectedProxyType">
<SelectTrigger class="h-9 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="socks5">SOCKS5</SelectItem>
<SelectItem value="http">HTTP CONNECT</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.proxyHost") }}</Label>
@@ -10875,17 +10909,43 @@ function openExternalUrl(url: string) {
</div>
</template>
<template v-else-if="selectedHttpTunnelLayer && !selectedLayerProfileId">
<div class="grid grid-cols-4 items-center gap-4">
<div class="grid min-w-0 grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.httpTunnelProtocol") }}</Label>
<div class="col-span-3 min-w-0">
<Select v-model="selectedHttpTunnelProtocol" :disabled="selectedHttpTunnelLayer!.enabled === false">
<SelectTrigger class="w-full min-w-0"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="dbx_tcp">{{ t("connection.httpTunnelProtocolDbx") }}</SelectItem>
<SelectItem v-if="form.db_type === 'mysql'" value="navicat_mysql">{{ t("connection.httpTunnelProtocolNavicat") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid min-w-0 grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.httpTunnelUrl") }}</Label>
<Input v-model="selectedHttpTunnelLayer!.url" class="col-span-3" placeholder="https://dbx.example.com/dbx_tunnel.php" :disabled="selectedHttpTunnelLayer!.enabled === false" />
<div class="col-span-3 min-w-0">
<Input
v-model="selectedHttpTunnelLayer!.url"
class="w-full min-w-0"
:placeholder="selectedHttpTunnelLayer!.protocol === 'navicat_mysql' ? 'https://example.com/ntunnel_mysql.php' : 'https://dbx.example.com/dbx_tunnel.php'"
:disabled="selectedHttpTunnelLayer!.enabled === false"
/>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div v-if="selectedHttpTunnelLayer!.protocol === 'navicat_mysql'" class="col-span-4 min-w-0 text-xs text-muted-foreground">
{{ t("connection.httpTunnelNavicatHint") }}
</div>
<div v-if="selectedHttpTunnelLayer!.protocol !== 'navicat_mysql'" class="grid min-w-0 grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.httpTunnelToken") }}</Label>
<PasswordInput v-model="selectedHttpTunnelLayer!.token" class="col-span-3" :placeholder="t('connection.httpTunnelTokenPlaceholder')" :disabled="selectedHttpTunnelLayer!.enabled === false" />
<div class="col-span-3 min-w-0">
<PasswordInput v-model="selectedHttpTunnelLayer!.token" class="w-full min-w-0" :placeholder="t('connection.httpTunnelTokenPlaceholder')" :disabled="selectedHttpTunnelLayer!.enabled === false" />
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div class="grid min-w-0 grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.httpTunnelConnectTimeout") }}</Label>
<Input v-model.number="selectedHttpTunnelLayer!.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" :disabled="selectedHttpTunnelLayer!.enabled === false" />
<div class="col-span-3 min-w-0">
<Input v-model.number="selectedHttpTunnelLayer!.connect_timeout_secs" type="number" min="1" max="300" step="1" class="w-full min-w-0" :disabled="selectedHttpTunnelLayer!.enabled === false" />
</div>
</div>
</template>
</template>
@@ -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. */
@@ -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() {
<Label class="text-xs">{{ t("connection.sshUser") }}</Label>
<Input v-model="selectedSsh.user" class="col-span-3" placeholder="root" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div class="grid min-w-0 grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshAuthMethod") }}</Label>
<Select :model-value="selectedSsh.auth_method || 'password'" @update:model-value="updateSshAuthMethod">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="password">{{ t("connection.sshAuthMethodPassword") }}</SelectItem>
<SelectItem value="key">{{ t("connection.sshAuthMethodKey") }}</SelectItem>
<SelectItem value="key+password">{{ t("connection.sshAuthMethodKeyPassword") }}</SelectItem>
<SelectItem value="agent">{{ t("connection.sshUseAgent") }}</SelectItem>
<SelectItem value="none">{{ t("connection.sshAuthMethodNone") }}</SelectItem>
</SelectContent>
</Select>
<div class="col-span-3 min-w-0">
<Select :model-value="selectedSsh.auth_method || 'password'" @update:model-value="updateSshAuthMethod">
<SelectTrigger class="h-9 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="password">{{ t("connection.sshAuthMethodPassword") }}</SelectItem>
<SelectItem value="key">{{ t("connection.sshAuthMethodKey") }}</SelectItem>
<SelectItem value="key+password">{{ t("connection.sshAuthMethodKeyPassword") }}</SelectItem>
<SelectItem value="agent">{{ t("connection.sshUseAgent") }}</SelectItem>
<SelectItem value="none">{{ t("connection.sshAuthMethodNone") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div v-if="!selectedSsh.auth_method || selectedSsh.auth_method === 'password' || selectedSsh.auth_method === 'key+password'" class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshPassword") }}</Label>
@@ -303,17 +325,19 @@ async function testSelected() {
</template>
<template v-else-if="selectedProxy">
<div class="grid grid-cols-4 items-center gap-4">
<div class="grid min-w-0 grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.proxyType") }}</Label>
<Select :model-value="selectedProxy.proxy_type || 'socks5'" @update:model-value="updateProxyType">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="socks5">SOCKS5</SelectItem>
<SelectItem value="http">HTTP CONNECT</SelectItem>
</SelectContent>
</Select>
<div class="col-span-3 min-w-0">
<Select :model-value="selectedProxy.proxy_type || 'socks5'" @update:model-value="updateProxyType">
<SelectTrigger class="h-9 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="socks5">SOCKS5</SelectItem>
<SelectItem value="http">HTTP CONNECT</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.proxyHost") }}</Label>
@@ -335,17 +359,41 @@ async function testSelected() {
</template>
<template v-else-if="selectedHttp">
<div class="grid grid-cols-4 items-center gap-4">
<div class="grid min-w-0 grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.httpTunnelProtocol") }}</Label>
<div class="col-span-3 min-w-0">
<Select :model-value="selectedHttp!.protocol" @update:model-value="updateHttpTunnelProtocol">
<SelectTrigger class="w-full min-w-0"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="dbx_tcp">{{ t("connection.httpTunnelProtocolDbx") }}</SelectItem>
<SelectItem value="navicat_mysql">{{ t("connection.httpTunnelProtocolNavicat") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid min-w-0 grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.httpTunnelUrl") }}</Label>
<Input v-model="selectedHttp!.url" class="col-span-3" placeholder="https://dbx.example.com/dbx_tunnel.php" />
<div class="col-span-3 min-w-0">
<Input v-model="selectedHttp!.url" class="w-full min-w-0" :placeholder="selectedHttp!.protocol === 'navicat_mysql' ? 'https://example.com/ntunnel_mysql.php' : 'https://dbx.example.com/dbx_tunnel.php'" />
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div v-if="selectedHttp!.protocol === 'navicat_mysql'" class="col-span-4 min-w-0 text-xs text-muted-foreground">
{{ t("connection.httpTunnelNavicatHint") }}
</div>
<div v-if="selectedHttp!.protocol === 'navicat_mysql'" class="col-span-4 min-w-0 text-xs">
<a class="text-primary underline underline-offset-2" href="https://help.navicat.com/hc/en-us/articles/218283457-Where-can-I-get-the-HTTP-tunnel-script-file" target="_blank" rel="noopener noreferrer">{{ t("connection.httpTunnelNavicatScriptHelp") }}</a>
</div>
<div v-if="selectedHttp!.protocol !== 'navicat_mysql'" class="grid min-w-0 grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.httpTunnelToken") }}</Label>
<PasswordInput v-model="selectedHttp!.token" class="col-span-3" :placeholder="t('connection.httpTunnelTokenPlaceholder')" />
<div class="col-span-3 min-w-0">
<PasswordInput v-model="selectedHttp!.token" class="w-full min-w-0" :placeholder="t('connection.httpTunnelTokenPlaceholder')" />
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div class="grid min-w-0 grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.httpTunnelConnectTimeout") }}</Label>
<Input v-model.number="selectedHttp!.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" />
<div class="col-span-3 min-w-0">
<Input v-model.number="selectedHttp!.connect_timeout_secs" type="number" min="1" max="300" step="1" class="w-full min-w-0" />
</div>
</div>
</template>
</template>
@@ -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<typeof import("@/lib/table/tableSelectSql")>()),
buildTableSelectSql: mocks.buildTableSelectSql,
@@ -104,6 +109,7 @@ function column(name: string) {
describe("useNavigationTargets openTableTarget", () => {
beforeEach(() => {
setActivePinia(createPinia());
clearTableMetadataCache();
vi.clearAllMocks();
mocks.tabs.length = 0;
@@ -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,
@@ -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()) {
+7
View File
@@ -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}",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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 証明書を選択",
+6
View File
@@ -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 인증서 선택",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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}",
+6
View File
@@ -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 憑證",
@@ -48,6 +48,7 @@ export function createTunnelProfile(type: TunnelProfileType): TunnelProfile {
enabled: true,
url: "",
token: "",
protocol: "dbx_tcp",
connect_timeout_secs: 10,
};
}
+1
View File
@@ -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;
@@ -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,
})
}
+89 -6
View File
@@ -3732,9 +3732,70 @@ impl AppState {
config: &ConnectionConfig,
) -> Result<ConnectionEndpoint, String> {
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::<Vec<_>>()
.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(|_| "<invalid-url>".to_string())
}
fn gaussdb_identifier_quote_from_query_result(result: &db::QueryResult) -> Option<String> {
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@%/"), "<invalid-url>");
}
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(),
}
@@ -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,
}),
],
@@ -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(),
});
+8
View File
@@ -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";
+3 -1
View File
@@ -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"] }
+77 -1
View File
@@ -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<u16, String> {
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<u16> {
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(|_| "<invalid-url>".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@%/"), "<invalid-url>");
}
#[test]
fn script_url_preserves_existing_query_and_appends_action() {
let url = script_url(
+1
View File
@@ -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;
@@ -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<Self, String> {
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<Vec<u8>, 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() { "<none>" } else { "<set>" },
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::<Vec<_>>().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::<String>();
let message = format!(
"Invalid Navicat HTTP tunnel response: HTTP {status}, content-type={}, final_url={}, bytes={}, header=[{}], preview={:?}",
content_type.as_deref().unwrap_or("<missing>"),
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<NavicatMysqlEndpoint>) -> 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<Token>) -> 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<u8> {
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<NavicatColumn>,
rows: Vec<Vec<Option<Vec<u8>>>>,
info: String,
}
#[derive(Debug, PartialEq, Eq)]
struct NavicatColumn {
name: Vec<u8>,
table: Vec<u8>,
column_type: u8,
flags: u32,
length: u32,
}
fn parse_navicat_response(data: &[u8]) -> Result<NavicatResult, String> {
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<Vec<u8>, 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<u32, String> {
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<String> {
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<u8> {
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<u8> {
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<u8>), 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<u8>, 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<u8>, 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<u8> {
let mut packet = vec![command];
packet.extend_from_slice(payload);
write_packet(client, 0, &packet).await.unwrap();
read_packet(client).await.unwrap().1
}
}
@@ -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,
})
}
+10
View File
@@ -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,
+28
View File
@@ -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.
+23 -2
View File
@@ -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
+3
View File
@@ -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');
+3 -1
View File
@@ -102,6 +102,8 @@ DBX 支持:
当数据库在内网中,外部只能访问一台内网 Web 服务器,并且不能使用 SSH/VPN 时,可以使用 HTTP 隧道。这类场景和 Navicat、SQL-Front 的 HTTP 隧道类似。
<Callout type="warning">PHP 脚本需要 PHP-FPM 的 `fastcgi_finish_request()`,或允许通过 `popen()` 启动 PHP CLI。若 CGI/FastCGI 共享主机不具备这两种能力,就无法运行此 TCP 隧道;脚本会返回 HTTP 503 和具体原因。目前没有提供 SQL 级 MySQL HTTP 适配器。</Callout>
### 部署脚本
把 `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 隧道** 层,并填写:
+3 -1
View File
@@ -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.
<Callout type="warn">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.</Callout>
### 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: