mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
feat(db): support TLS client certificates
This commit is contained in:
Generated
+1
@@ -1861,6 +1861,7 @@ dependencies = [
|
||||
"russh",
|
||||
"rust_decimal",
|
||||
"rustls 0.23.40",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
@@ -29,9 +29,11 @@ import {
|
||||
FilePlus2,
|
||||
FolderOpen,
|
||||
Grid3X3,
|
||||
KeyRound,
|
||||
Link2,
|
||||
List,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
type DbOption = { value: string; label: string };
|
||||
@@ -573,7 +575,63 @@ const tlsCapableDatabaseTypes = new Set<DatabaseType>([
|
||||
"elasticsearch",
|
||||
]);
|
||||
const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type));
|
||||
const supportsCaCertificatePath = computed(() => form.value.db_type === "mysql" || form.value.db_type === "clickhouse");
|
||||
const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse");
|
||||
const bareMysqlProfiles = new Set(["doris", "starrocks", "selectdb", "oceanbase"]);
|
||||
const supportsMysqlTlsOptions = computed(
|
||||
() => form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value),
|
||||
);
|
||||
const mysqlTlsMode = computed({
|
||||
get: () => mysqlTlsModeFromParams(form.value.url_params, form.value.ssl),
|
||||
set: (value: string) => {
|
||||
form.value.ssl = value !== "preferred" && value !== "disabled";
|
||||
form.value.url_params = applyMysqlTlsMode(form.value.url_params, value);
|
||||
},
|
||||
});
|
||||
const mysqlClientCertPath = computed({
|
||||
get: () => getUrlParam(form.value.url_params, "ssl-cert") || getUrlParam(form.value.url_params, "sslcert"),
|
||||
set: (value: string) => {
|
||||
let next = setUrlParam(form.value.url_params, "sslcert", "");
|
||||
form.value.url_params = setUrlParam(next, "ssl-cert", value);
|
||||
},
|
||||
});
|
||||
const mysqlClientKeyPath = computed({
|
||||
get: () => getUrlParam(form.value.url_params, "ssl-key") || getUrlParam(form.value.url_params, "sslkey"),
|
||||
set: (value: string) => {
|
||||
let next = setUrlParam(form.value.url_params, "sslkey", "");
|
||||
form.value.url_params = setUrlParam(next, "ssl-key", value);
|
||||
},
|
||||
});
|
||||
const nativePostgresTlsDatabaseTypes = new Set<DatabaseType>(["postgres", "redshift", "gaussdb", "opengauss"]);
|
||||
const supportsPostgresTlsOptions = computed(() => nativePostgresTlsDatabaseTypes.has(form.value.db_type));
|
||||
const postgresTlsMode = computed({
|
||||
get: () => {
|
||||
const value = normalizePostgresSslMode(getUrlParam(form.value.url_params, "sslmode"));
|
||||
if (value) return value;
|
||||
return form.value.ssl ? "require" : "disable";
|
||||
},
|
||||
set: (value: string) => {
|
||||
form.value.ssl = value !== "disable";
|
||||
form.value.url_params = setUrlParam(form.value.url_params, "sslmode", value === "prefer" ? "" : value);
|
||||
},
|
||||
});
|
||||
const postgresRootCertPath = computed({
|
||||
get: () => getUrlParam(form.value.url_params, "sslrootcert"),
|
||||
set: (value: string) => {
|
||||
form.value.url_params = setUrlParam(form.value.url_params, "sslrootcert", value);
|
||||
},
|
||||
});
|
||||
const postgresClientCertPath = computed({
|
||||
get: () => getUrlParam(form.value.url_params, "sslcert"),
|
||||
set: (value: string) => {
|
||||
form.value.url_params = setUrlParam(form.value.url_params, "sslcert", value);
|
||||
},
|
||||
});
|
||||
const postgresClientKeyPath = computed({
|
||||
get: () => getUrlParam(form.value.url_params, "sslkey"),
|
||||
set: (value: string) => {
|
||||
form.value.url_params = setUrlParam(form.value.url_params, "sslkey", value);
|
||||
},
|
||||
});
|
||||
const canUseSsh = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access");
|
||||
const canUseProxy = computed(
|
||||
() => form.value.db_type !== "sqlite" && form.value.db_type !== "duckdb" && form.value.db_type !== "access",
|
||||
@@ -712,7 +770,7 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
|
||||
config.redis_sentinel_password = undefined;
|
||||
config.redis_sentinel_tls = undefined;
|
||||
}
|
||||
if (config.db_type !== "clickhouse") {
|
||||
if (config.db_type !== "mysql" && config.db_type !== "clickhouse") {
|
||||
config.ca_cert_path = undefined;
|
||||
} else {
|
||||
config.ca_cert_path = config.ca_cert_path?.trim() || "";
|
||||
@@ -730,6 +788,92 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
function getUrlParam(params: string | undefined, key: string): string {
|
||||
const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, ""));
|
||||
return parsed.get(key) || "";
|
||||
}
|
||||
|
||||
function setUrlParam(params: string | undefined, key: string, value: string): string {
|
||||
const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, ""));
|
||||
const normalized = value.trim();
|
||||
if (normalized) {
|
||||
parsed.set(key, normalized);
|
||||
} else {
|
||||
parsed.delete(key);
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function deleteUrlParams(params: string | undefined, keys: string[]): string {
|
||||
const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, ""));
|
||||
for (const key of keys) {
|
||||
parsed.delete(key);
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function mysqlTlsModeFromParams(params: string | undefined, ssl: boolean | undefined): string {
|
||||
const sslMode = getUrlParam(params, "ssl-mode") || getUrlParam(params, "sslmode");
|
||||
switch (sslMode.trim().toLowerCase().replace("-", "_")) {
|
||||
case "disabled":
|
||||
case "disable":
|
||||
return "disabled";
|
||||
case "preferred":
|
||||
case "prefer":
|
||||
return "preferred";
|
||||
case "required":
|
||||
case "require":
|
||||
return "required";
|
||||
case "verify_ca":
|
||||
return "verify_ca";
|
||||
case "verify_identity":
|
||||
return "verify_identity";
|
||||
}
|
||||
|
||||
if (!ssl && getUrlParam(params, "require_ssl").toLowerCase() !== "true") return "preferred";
|
||||
if (getUrlParam(params, "verify_identity").toLowerCase() === "true") return "verify_identity";
|
||||
if (getUrlParam(params, "verify_ca").toLowerCase() === "true") return "verify_ca";
|
||||
return "required";
|
||||
}
|
||||
|
||||
function applyMysqlTlsMode(params: string | undefined, mode: string): string {
|
||||
let next = deleteUrlParams(params, ["ssl-mode", "sslmode", "require_ssl", "verify_ca", "verify_identity"]);
|
||||
if (mode === "disabled") {
|
||||
return setUrlParam(next, "ssl-mode", "disabled");
|
||||
}
|
||||
if (mode === "preferred") {
|
||||
return next;
|
||||
}
|
||||
|
||||
next = setUrlParam(next, "require_ssl", "true");
|
||||
if (mode === "required") {
|
||||
next = setUrlParam(next, "verify_ca", "false");
|
||||
return setUrlParam(next, "verify_identity", "false");
|
||||
}
|
||||
if (mode === "verify_ca") {
|
||||
next = setUrlParam(next, "verify_ca", "true");
|
||||
return setUrlParam(next, "verify_identity", "false");
|
||||
}
|
||||
next = setUrlParam(next, "verify_ca", "true");
|
||||
return setUrlParam(next, "verify_identity", "true");
|
||||
}
|
||||
|
||||
function normalizePostgresSslMode(value: string): string {
|
||||
switch (value.trim().toLowerCase()) {
|
||||
case "disable":
|
||||
case "prefer":
|
||||
case "require":
|
||||
case "verify-ca":
|
||||
case "verify-full":
|
||||
return value.trim().toLowerCase();
|
||||
case "verify_identity":
|
||||
case "verify-identity":
|
||||
return "verify-full";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRedisSentinelNodes(value: string): string {
|
||||
return value
|
||||
.split(/[\n,;]+/)
|
||||
@@ -985,6 +1129,55 @@ async function browseCaCertPath() {
|
||||
}
|
||||
}
|
||||
|
||||
async function browseMysqlTlsFile(target: "cert" | "key") {
|
||||
if (isTauriRuntime()) {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
title: target === "cert" ? t("connection.mysqlClientCertBrowse") : t("connection.mysqlClientKeyBrowse"),
|
||||
multiple: false,
|
||||
filters: [
|
||||
{ name: "PEM", extensions: ["pem", "crt", "cer", "key"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
if (selected && typeof selected === "string") {
|
||||
if (target === "cert") {
|
||||
mysqlClientCertPath.value = selected;
|
||||
} else {
|
||||
mysqlClientKeyPath.value = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function browsePostgresTlsFile(target: "root" | "cert" | "key") {
|
||||
if (isTauriRuntime()) {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
title:
|
||||
target === "root"
|
||||
? t("connection.postgresRootCertBrowse")
|
||||
: target === "cert"
|
||||
? t("connection.postgresClientCertBrowse")
|
||||
: t("connection.postgresClientKeyBrowse"),
|
||||
multiple: false,
|
||||
filters: [
|
||||
{ name: "PEM", extensions: ["pem", "crt", "cer", "key"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
if (selected && typeof selected === "string") {
|
||||
if (target === "root") {
|
||||
postgresRootCertPath.value = selected;
|
||||
} else if (target === "cert") {
|
||||
postgresClientCertPath.value = selected;
|
||||
} else {
|
||||
postgresClientKeyPath.value = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function browseDbFilePath() {
|
||||
if (isTauriRuntime()) {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
@@ -1708,7 +1901,10 @@ function openExternalUrl(url: string) {
|
||||
|
||||
<TabsContent v-if="supportsTlsToggle" value="tls" class="m-0">
|
||||
<div class="grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<div
|
||||
v-if="!supportsPostgresTlsOptions && !supportsMysqlTlsOptions"
|
||||
class="grid grid-cols-4 items-center gap-4"
|
||||
>
|
||||
<Label class="text-right text-xs">SSL/TLS</Label>
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.ssl" class="mr-0" />
|
||||
@@ -1716,6 +1912,232 @@ function openExternalUrl(url: string) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="supportsMysqlTlsOptions">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.mysqlTlsMode") }}</Label>
|
||||
<Select v-model="mysqlTlsMode">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="preferred">{{ t("connection.mysqlTlsModePreferred") }}</SelectItem>
|
||||
<SelectItem value="disabled">{{ t("connection.mysqlTlsModeDisabled") }}</SelectItem>
|
||||
<SelectItem value="required">{{ t("connection.mysqlTlsModeRequired") }}</SelectItem>
|
||||
<SelectItem value="verify_ca">{{ t("connection.mysqlTlsModeVerifyCa") }}</SelectItem>
|
||||
<SelectItem value="verify_identity">{{
|
||||
t("connection.mysqlTlsModeVerifyIdentity")
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="pt-2 text-right text-xs">
|
||||
<span class="inline-flex items-center justify-end gap-1">
|
||||
<ShieldCheck class="h-3.5 w-3.5" />
|
||||
{{ t("connection.caCertPath") }}
|
||||
</span>
|
||||
</Label>
|
||||
<div class="col-span-3 space-y-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<Input
|
||||
v-model="form.ca_cert_path"
|
||||
class="flex-1"
|
||||
:placeholder="t('connection.caCertPathPlaceholder')"
|
||||
:disabled="mysqlTlsMode === 'preferred' || mysqlTlsMode === 'disabled'"
|
||||
/>
|
||||
<Tooltip v-if="isDesktop">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0"
|
||||
:disabled="mysqlTlsMode === 'preferred' || mysqlTlsMode === 'disabled'"
|
||||
@click="browseCaCertPath"
|
||||
>
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.caCertPathBrowse") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p class="text-[11px] leading-4 text-muted-foreground">
|
||||
{{ t("connection.mysqlCaCertHint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="pt-2 text-right text-xs">
|
||||
<span class="inline-flex items-center justify-end gap-1">
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
{{ t("connection.mysqlClientCert") }}
|
||||
</span>
|
||||
</Label>
|
||||
<div class="col-span-3 grid gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<Input
|
||||
v-model="mysqlClientCertPath"
|
||||
class="flex-1"
|
||||
:placeholder="t('connection.mysqlClientCertPlaceholder')"
|
||||
:disabled="mysqlTlsMode === 'preferred' || mysqlTlsMode === 'disabled'"
|
||||
/>
|
||||
<Tooltip v-if="isDesktop">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0"
|
||||
:disabled="mysqlTlsMode === 'preferred' || mysqlTlsMode === 'disabled'"
|
||||
@click="browseMysqlTlsFile('cert')"
|
||||
>
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.mysqlClientCertBrowse") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Input
|
||||
v-model="mysqlClientKeyPath"
|
||||
class="flex-1"
|
||||
:placeholder="t('connection.mysqlClientKeyPlaceholder')"
|
||||
:disabled="mysqlTlsMode === 'preferred' || mysqlTlsMode === 'disabled'"
|
||||
/>
|
||||
<Tooltip v-if="isDesktop">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0"
|
||||
:disabled="mysqlTlsMode === 'preferred' || mysqlTlsMode === 'disabled'"
|
||||
@click="browseMysqlTlsFile('key')"
|
||||
>
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.mysqlClientKeyBrowse") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p class="text-[11px] leading-4 text-muted-foreground">
|
||||
{{ t("connection.mysqlClientCertHint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="supportsPostgresTlsOptions">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.postgresSslMode") }}</Label>
|
||||
<Select v-model="postgresTlsMode">
|
||||
<SelectTrigger class="col-span-3 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="disable">{{ t("connection.postgresSslModeDisable") }}</SelectItem>
|
||||
<SelectItem value="prefer">{{ t("connection.postgresSslModePrefer") }}</SelectItem>
|
||||
<SelectItem value="require">{{ t("connection.postgresSslModeRequire") }}</SelectItem>
|
||||
<SelectItem value="verify-ca">{{ t("connection.postgresSslModeVerifyCa") }}</SelectItem>
|
||||
<SelectItem value="verify-full">{{ t("connection.postgresSslModeVerifyFull") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="pt-2 text-right text-xs">
|
||||
<span class="inline-flex items-center justify-end gap-1">
|
||||
<ShieldCheck class="h-3.5 w-3.5" />
|
||||
{{ t("connection.postgresServerCert") }}
|
||||
</span>
|
||||
</Label>
|
||||
<div class="col-span-3 space-y-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<Input
|
||||
v-model="postgresRootCertPath"
|
||||
class="flex-1"
|
||||
:placeholder="t('connection.postgresRootCertPlaceholder')"
|
||||
:disabled="postgresTlsMode === 'disable'"
|
||||
/>
|
||||
<Tooltip v-if="isDesktop">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0"
|
||||
:disabled="postgresTlsMode === 'disable'"
|
||||
@click="browsePostgresTlsFile('root')"
|
||||
>
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.postgresRootCertBrowse") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p class="text-[11px] leading-4 text-muted-foreground">
|
||||
{{ t("connection.postgresRootCertHint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="pt-2 text-right text-xs">
|
||||
<span class="inline-flex items-center justify-end gap-1">
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
{{ t("connection.postgresClientCert") }}
|
||||
</span>
|
||||
</Label>
|
||||
<div class="col-span-3 grid gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<Input
|
||||
v-model="postgresClientCertPath"
|
||||
class="flex-1"
|
||||
:placeholder="t('connection.postgresClientCertPlaceholder')"
|
||||
:disabled="postgresTlsMode === 'disable'"
|
||||
/>
|
||||
<Tooltip v-if="isDesktop">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0"
|
||||
:disabled="postgresTlsMode === 'disable'"
|
||||
@click="browsePostgresTlsFile('cert')"
|
||||
>
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.postgresClientCertBrowse") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Input
|
||||
v-model="postgresClientKeyPath"
|
||||
class="flex-1"
|
||||
:placeholder="t('connection.postgresClientKeyPlaceholder')"
|
||||
:disabled="postgresTlsMode === 'disable'"
|
||||
/>
|
||||
<Tooltip v-if="isDesktop">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0"
|
||||
:disabled="postgresTlsMode === 'disable'"
|
||||
@click="browsePostgresTlsFile('key')"
|
||||
>
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.postgresClientKeyBrowse") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p class="text-[11px] leading-4 text-muted-foreground">
|
||||
{{ t("connection.postgresClientCertHint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="supportsCaCertificatePath" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.caCertPath") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
|
||||
@@ -122,6 +122,37 @@ export default {
|
||||
caCertPath: "CA Certificate",
|
||||
caCertPathPlaceholder: "Optional, e.g. ~/.yandex/RootCA.crt",
|
||||
caCertPathBrowse: "Browse certificate",
|
||||
mysqlTlsMode: "TLS Mode",
|
||||
mysqlTlsModePreferred: "Preferred",
|
||||
mysqlTlsModeDisabled: "Disabled",
|
||||
mysqlTlsModeRequired: "Required",
|
||||
mysqlTlsModeVerifyCa: "Verify CA",
|
||||
mysqlTlsModeVerifyIdentity: "Verify Identity",
|
||||
mysqlCaCertHint: "Required for Verify CA and Verify Identity when the server certificate uses a private CA.",
|
||||
mysqlClientCert: "Client Auth",
|
||||
mysqlClientCertPlaceholder: "/path/to/client.crt",
|
||||
mysqlClientKeyPlaceholder: "/path/to/client.key",
|
||||
mysqlClientCertHint: "Client certificate and private key must be provided together when MySQL requires mTLS.",
|
||||
mysqlClientCertBrowse: "Choose client certificate",
|
||||
mysqlClientKeyBrowse: "Choose client private key",
|
||||
postgresSslMode: "TLS Mode",
|
||||
postgresSslModeDisable: "Disable",
|
||||
postgresSslModePrefer: "Prefer",
|
||||
postgresSslModeRequire: "Require",
|
||||
postgresSslModeVerifyCa: "Verify CA",
|
||||
postgresSslModeVerifyFull: "Verify Full",
|
||||
postgresServerCert: "Server CA",
|
||||
postgresRootCertPlaceholder: "/path/to/ca.crt",
|
||||
postgresRootCertHint:
|
||||
"Use this for verify-ca or verify-full when the server certificate is signed by a private CA.",
|
||||
postgresRootCertBrowse: "Choose CA certificate",
|
||||
postgresClientCert: "Client Auth",
|
||||
postgresClientCertPlaceholder: "/path/to/client.crt",
|
||||
postgresClientKeyPlaceholder: "/path/to/client.key",
|
||||
postgresClientCertHint:
|
||||
"Client certificate and private key must be provided together when PostgreSQL requires mTLS.",
|
||||
postgresClientCertBrowse: "Choose client certificate",
|
||||
postgresClientKeyBrowse: "Choose client private key",
|
||||
connectionUrlOptional: "URL (optional)",
|
||||
connectionUrlPlaceholder: "postgresql://user:pass{'@'}host:5432/db?sslmode=require",
|
||||
parseConnectionUrl: "Parse connection URL",
|
||||
|
||||
@@ -112,6 +112,39 @@ export default {
|
||||
driverName: "Nombre del driver",
|
||||
driverNamePlaceholder: "Nombre del proveedor o entorno",
|
||||
urlParams: "Parámetros de URL",
|
||||
sslEnable: "Habilitar conexión cifrada",
|
||||
caCertPath: "Certificado CA",
|
||||
caCertPathPlaceholder: "Opcional, p. ej. ~/.yandex/RootCA.crt",
|
||||
caCertPathBrowse: "Buscar certificado",
|
||||
mysqlTlsMode: "Modo TLS",
|
||||
mysqlTlsModePreferred: "Preferido",
|
||||
mysqlTlsModeDisabled: "Deshabilitado",
|
||||
mysqlTlsModeRequired: "Requerido",
|
||||
mysqlTlsModeVerifyCa: "Verificar CA",
|
||||
mysqlTlsModeVerifyIdentity: "Verificar identidad",
|
||||
mysqlCaCertHint: "Requerido para Verificar CA e Identidad cuando el certificado usa una CA privada.",
|
||||
mysqlClientCert: "Autenticación cliente",
|
||||
mysqlClientCertPlaceholder: "/ruta/a/client.crt",
|
||||
mysqlClientKeyPlaceholder: "/ruta/a/client.key",
|
||||
mysqlClientCertHint: "El certificado y la clave privada deben indicarse juntos si MySQL requiere mTLS.",
|
||||
mysqlClientCertBrowse: "Elegir certificado cliente",
|
||||
mysqlClientKeyBrowse: "Elegir clave privada cliente",
|
||||
postgresSslMode: "Modo TLS",
|
||||
postgresSslModeDisable: "Deshabilitar",
|
||||
postgresSslModePrefer: "Preferir",
|
||||
postgresSslModeRequire: "Requerir",
|
||||
postgresSslModeVerifyCa: "Verificar CA",
|
||||
postgresSslModeVerifyFull: "Verificar completo",
|
||||
postgresServerCert: "CA del servidor",
|
||||
postgresRootCertPlaceholder: "/ruta/a/ca.crt",
|
||||
postgresRootCertHint: "Úsalo con verify-ca o verify-full cuando el certificado del servidor use una CA privada.",
|
||||
postgresRootCertBrowse: "Elegir certificado CA",
|
||||
postgresClientCert: "Autenticación cliente",
|
||||
postgresClientCertPlaceholder: "/ruta/a/client.crt",
|
||||
postgresClientKeyPlaceholder: "/ruta/a/client.key",
|
||||
postgresClientCertHint: "El certificado y la clave privada deben indicarse juntos si PostgreSQL requiere mTLS.",
|
||||
postgresClientCertBrowse: "Elegir certificado cliente",
|
||||
postgresClientKeyBrowse: "Elegir clave privada cliente",
|
||||
connectionUrlOptional: "URL (opcional)",
|
||||
connectionUrlPlaceholder: "postgresql://usuario:contraseña{'@'}host:5432/db?sslmode=require",
|
||||
parseConnectionUrl: "Analizar URL de conexión",
|
||||
|
||||
@@ -121,6 +121,35 @@ export default {
|
||||
caCertPath: "CA 证书",
|
||||
caCertPathPlaceholder: "可选,例如 ~/.yandex/RootCA.crt",
|
||||
caCertPathBrowse: "选择证书",
|
||||
mysqlTlsMode: "TLS 模式",
|
||||
mysqlTlsModePreferred: "优先使用",
|
||||
mysqlTlsModeDisabled: "禁用",
|
||||
mysqlTlsModeRequired: "必须使用",
|
||||
mysqlTlsModeVerifyCa: "验证 CA",
|
||||
mysqlTlsModeVerifyIdentity: "验证身份",
|
||||
mysqlCaCertHint: "服务端证书由私有 CA 签发时,Verify CA / Verify Identity 模式需要填写。",
|
||||
mysqlClientCert: "客户端认证",
|
||||
mysqlClientCertPlaceholder: "/path/to/client.crt",
|
||||
mysqlClientKeyPlaceholder: "/path/to/client.key",
|
||||
mysqlClientCertHint: "MySQL 要求 mTLS 时,客户端证书和私钥必须一起填写。",
|
||||
mysqlClientCertBrowse: "选择客户端证书",
|
||||
mysqlClientKeyBrowse: "选择客户端私钥",
|
||||
postgresSslMode: "TLS 模式",
|
||||
postgresSslModeDisable: "禁用",
|
||||
postgresSslModePrefer: "优先使用",
|
||||
postgresSslModeRequire: "必须使用",
|
||||
postgresSslModeVerifyCa: "验证 CA",
|
||||
postgresSslModeVerifyFull: "完整验证",
|
||||
postgresServerCert: "服务端 CA",
|
||||
postgresRootCertPlaceholder: "/path/to/ca.crt",
|
||||
postgresRootCertHint: "服务端证书由私有 CA 签发时,在 verify-ca 或 verify-full 模式下填写。",
|
||||
postgresRootCertBrowse: "选择 CA 证书",
|
||||
postgresClientCert: "客户端认证",
|
||||
postgresClientCertPlaceholder: "/path/to/client.crt",
|
||||
postgresClientKeyPlaceholder: "/path/to/client.key",
|
||||
postgresClientCertHint: "PostgreSQL 要求 mTLS 时,客户端证书和私钥必须一起填写。",
|
||||
postgresClientCertBrowse: "选择客户端证书",
|
||||
postgresClientKeyBrowse: "选择客户端私钥",
|
||||
connectionUrlOptional: "URL(可选)",
|
||||
connectionUrlPlaceholder: "postgresql://user:pass{'@'}host:5432/db?sslmode=require",
|
||||
parseConnectionUrl: "解析连接 URL",
|
||||
|
||||
@@ -29,6 +29,7 @@ mysql_async = { version = "0.36", default-features = false, features = ["default
|
||||
sqlparser = "0.62.0"
|
||||
redis = { version = "0.32.2", features = ["tokio-comp", "tls-rustls", "tokio-rustls-comp", "sentinel"] }
|
||||
rustls = { version = "0.23", features = ["aws-lc-rs"] }
|
||||
rustls-pemfile = "2.2"
|
||||
duckdb = "1.3.2"
|
||||
tiberius = { version = "0.12.3", default-features = false, features = ["tds73", "chrono", "rust_decimal", "rustls", "sql-browser-tokio"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "socks"] }
|
||||
|
||||
@@ -2,6 +2,7 @@ use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||
use futures::StreamExt;
|
||||
use mysql_async::consts::ColumnType;
|
||||
use mysql_async::prelude::*;
|
||||
use percent_encoding::percent_decode_str;
|
||||
use rust_decimal::Decimal;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
@@ -14,6 +15,8 @@ use crate::types::{
|
||||
ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, ObjectInfo, QueryResult, TableInfo, TriggerInfo,
|
||||
};
|
||||
|
||||
use super::file_validator::validate_file_path;
|
||||
|
||||
pub type MySqlPool = mysql_async::Pool;
|
||||
|
||||
fn quote_value(s: &str) -> String {
|
||||
@@ -280,8 +283,17 @@ pub async fn connect_with_ca_cert(url: &str, ca_cert_path: Option<&str>) -> Resu
|
||||
result.map(|_| pool)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
struct MySqlTlsFiles {
|
||||
sslcert: Option<String>,
|
||||
sslkey: Option<String>,
|
||||
}
|
||||
|
||||
fn create_pool(url: &str, ca_cert_path: Option<&str>) -> Result<MySqlPool, String> {
|
||||
let opts = mysql_async::Opts::from_url(&mysql_async_url(url)).map_err(|e| format!("Invalid MySQL URL: {e}"))?;
|
||||
let tls_url = mysql_tls_url(url)?;
|
||||
let opts =
|
||||
mysql_async::Opts::from_url(&mysql_async_url(&tls_url.url)).map_err(|e| format!("Invalid MySQL URL: {e}"))?;
|
||||
let base_ssl_opts = opts.ssl_opts().cloned();
|
||||
let pool_opts = mysql_async::PoolOpts::new()
|
||||
.with_constraints(mysql_async::PoolConstraints::new(1, 1).unwrap())
|
||||
.with_inactive_connection_ttl(Duration::from_secs(300));
|
||||
@@ -290,17 +302,109 @@ fn create_pool(url: &str, ca_cert_path: Option<&str>) -> Result<MySqlPool, Strin
|
||||
.prefer_socket(false)
|
||||
.pool_opts(Some(pool_opts))
|
||||
.setup(mysql_setup_queries(url));
|
||||
if let Some(ca_cert_path) =
|
||||
ca_cert_path.map(str::trim).filter(|path| !path.is_empty()).filter(|_| mysql_url_requires_ssl(url))
|
||||
{
|
||||
let ssl_opts = mysql_async::SslOpts::default()
|
||||
.with_root_certs(vec![PathBuf::from(ca_cert_path).into()])
|
||||
.with_danger_skip_domain_validation(true);
|
||||
if let Some(ssl_opts) = mysql_ssl_opts(base_ssl_opts, url, ca_cert_path, &tls_url.files)? {
|
||||
builder = builder.ssl_opts(ssl_opts);
|
||||
}
|
||||
Ok(MySqlPool::new(builder))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct MySqlTlsUrl {
|
||||
url: String,
|
||||
files: MySqlTlsFiles,
|
||||
}
|
||||
|
||||
fn mysql_tls_url(url: &str) -> Result<MySqlTlsUrl, String> {
|
||||
let Some(query_start) = url.find('?') else {
|
||||
return Ok(MySqlTlsUrl { url: url.to_string(), files: MySqlTlsFiles::default() });
|
||||
};
|
||||
|
||||
let prefix = &url[..query_start];
|
||||
let suffix = &url[query_start + 1..];
|
||||
let (query_string, fragment) = suffix.split_once('#').map_or((suffix, ""), |(query, fragment)| (query, fragment));
|
||||
let mut files = MySqlTlsFiles::default();
|
||||
let mut kept_params = Vec::new();
|
||||
|
||||
for param in query_string.split('&') {
|
||||
if param.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((key, value)) = param.split_once('=') else {
|
||||
kept_params.push(param.to_string());
|
||||
continue;
|
||||
};
|
||||
|
||||
if mysql_tls_file_param_is(key, "cert") || mysql_tls_file_param_is(key, "key") {
|
||||
let decoded = percent_decode_str(value)
|
||||
.decode_utf8()
|
||||
.map_err(|_| format!("Invalid URL encoding in {key}"))?
|
||||
.into_owned();
|
||||
validate_file_path(&decoded, |_| false).map_err(|e| format!("{key}: {e}"))?;
|
||||
|
||||
if mysql_tls_file_param_is(key, "cert") {
|
||||
files.sslcert = Some(decoded);
|
||||
} else {
|
||||
files.sslkey = Some(decoded);
|
||||
}
|
||||
} else {
|
||||
kept_params.push(param.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut sanitized_url = prefix.to_string();
|
||||
if !kept_params.is_empty() {
|
||||
sanitized_url.push('?');
|
||||
sanitized_url.push_str(&kept_params.join("&"));
|
||||
}
|
||||
if !fragment.is_empty() {
|
||||
sanitized_url.push('#');
|
||||
sanitized_url.push_str(fragment);
|
||||
}
|
||||
|
||||
Ok(MySqlTlsUrl { url: sanitized_url, files })
|
||||
}
|
||||
|
||||
fn mysql_tls_file_param_is(key: &str, target: &str) -> bool {
|
||||
let normalized = key.to_ascii_lowercase().replace(['-', '_'], "");
|
||||
normalized == format!("ssl{target}")
|
||||
}
|
||||
|
||||
fn mysql_ssl_opts(
|
||||
base_ssl_opts: Option<mysql_async::SslOpts>,
|
||||
url: &str,
|
||||
ca_cert_path: Option<&str>,
|
||||
files: &MySqlTlsFiles,
|
||||
) -> Result<Option<mysql_async::SslOpts>, String> {
|
||||
let ca_cert_path = ca_cert_path.map(str::trim).filter(|path| !path.is_empty());
|
||||
let has_client_identity = files.sslcert.as_deref().is_some() || files.sslkey.as_deref().is_some();
|
||||
if !mysql_url_requires_ssl(url) && !has_client_identity {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut ssl_opts = base_ssl_opts.unwrap_or_default();
|
||||
if let Some(ca_cert_path) = ca_cert_path.filter(|_| mysql_url_requires_ssl(url) || has_client_identity) {
|
||||
ssl_opts = ssl_opts.with_root_certs(vec![PathBuf::from(ca_cert_path).into()]);
|
||||
if !mysql_url_verifies_identity(url) {
|
||||
ssl_opts = ssl_opts.with_danger_skip_domain_validation(true);
|
||||
}
|
||||
}
|
||||
|
||||
match (files.sslcert.as_deref(), files.sslkey.as_deref()) {
|
||||
(Some(cert_path), Some(key_path)) => {
|
||||
ssl_opts = ssl_opts.with_client_identity(Some(mysql_async::ClientIdentity::new(
|
||||
PathBuf::from(cert_path).into(),
|
||||
PathBuf::from(key_path).into(),
|
||||
)));
|
||||
}
|
||||
(Some(_), None) => return Err("MySQL ssl-cert requires ssl-key".to_string()),
|
||||
(None, Some(_)) => return Err("MySQL ssl-key requires ssl-cert".to_string()),
|
||||
(None, None) => {}
|
||||
}
|
||||
|
||||
Ok(Some(ssl_opts))
|
||||
}
|
||||
|
||||
fn mysql_setup_queries(url: &str) -> Vec<String> {
|
||||
let charset = mysql_connection_charset(url).unwrap_or("utf8mb4");
|
||||
vec![format!("SET NAMES {charset}")]
|
||||
@@ -424,6 +528,8 @@ fn mysql_url_requires_ssl(url: &str) -> bool {
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
(key.eq_ignore_ascii_case("require_ssl") && value.eq_ignore_ascii_case("true"))
|
||||
|| mysql_tls_file_param_is(key, "cert")
|
||||
|| mysql_tls_file_param_is(key, "key")
|
||||
|| ((key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode"))
|
||||
&& matches!(
|
||||
value.to_ascii_lowercase().replace('-', "_").as_str(),
|
||||
@@ -432,6 +538,22 @@ fn mysql_url_requires_ssl(url: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn mysql_url_verifies_identity(url: &str) -> bool {
|
||||
let Some((_, query)) = url.split_once('?') else {
|
||||
return false;
|
||||
};
|
||||
query.split('&').any(|segment| {
|
||||
let Some((key, value)) = segment.split_once('=') else {
|
||||
return false;
|
||||
};
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
(key.eq_ignore_ascii_case("verify_identity") && value.eq_ignore_ascii_case("true"))
|
||||
|| ((key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode"))
|
||||
&& matches!(value.to_ascii_lowercase().replace('-', "_").as_str(), "verify_identity"))
|
||||
})
|
||||
}
|
||||
|
||||
fn mysql_async_url(url: &str) -> Cow<'_, str> {
|
||||
let Some((base, query)) = url.split_once('?') else {
|
||||
return Cow::Borrowed(url);
|
||||
@@ -1082,6 +1204,43 @@ mod tests {
|
||||
assert!(mysql_error_should_retry_without_ssl(error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_tls_url_strips_client_identity_params_before_driver_parse() {
|
||||
let dir = std::env::temp_dir();
|
||||
let cert = dir.join(format!("dbx-mysql-client-cert-{}.pem", std::process::id()));
|
||||
let key = dir.join(format!("dbx-mysql-client-key-{}.pem", std::process::id()));
|
||||
std::fs::write(&cert, "not a real cert").unwrap();
|
||||
std::fs::write(&key, "not a real key").unwrap();
|
||||
|
||||
let url = format!(
|
||||
"mysql://root:secret@localhost/test?require_ssl=true&ssl-cert={}&ssl-key={}&charset=utf8mb4",
|
||||
cert.display(),
|
||||
key.display()
|
||||
);
|
||||
let parsed = mysql_tls_url(&url).unwrap();
|
||||
|
||||
assert_eq!(parsed.url, "mysql://root:secret@localhost/test?require_ssl=true&charset=utf8mb4");
|
||||
assert_eq!(parsed.files.sslcert.as_deref(), Some(cert.to_str().unwrap()));
|
||||
assert_eq!(parsed.files.sslkey.as_deref(), Some(key.to_str().unwrap()));
|
||||
mysql_async::Opts::from_url(&mysql_async_url(&parsed.url)).unwrap();
|
||||
|
||||
let _ = std::fs::remove_file(cert);
|
||||
let _ = std::fs::remove_file(key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_tls_rejects_unpaired_client_cert_and_key() {
|
||||
let files = MySqlTlsFiles { sslcert: Some("/tmp/client.crt".to_string()), sslkey: None };
|
||||
|
||||
let error = mysql_ssl_opts(None, "mysql://root@localhost/db?require_ssl=true", None, &files).unwrap_err();
|
||||
assert!(error.contains("ssl-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_tls_client_identity_requires_ssl() {
|
||||
assert!(mysql_url_requires_ssl("mysql://root@localhost/db?ssl-cert=/tmp/client.crt&ssl-key=/tmp/client.key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_unknown_error_can_retry_with_text_protocol() {
|
||||
let error = "error returned from database: 1105 (HY000): Unknown error";
|
||||
|
||||
@@ -4,8 +4,12 @@ use futures::{SinkExt, StreamExt};
|
||||
use percent_encoding::percent_decode_str;
|
||||
use rust_decimal::Decimal;
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::client::verify_server_cert_signed_by_trust_anchor;
|
||||
use rustls::crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider};
|
||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
|
||||
use rustls::server::ParsedCertificate;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
@@ -319,17 +323,22 @@ async fn execute_select_query(
|
||||
}
|
||||
|
||||
pub async fn connect(url: &str) -> Result<Pool, String> {
|
||||
validate_postgres_ssl_paths(url)?;
|
||||
let postgres_url = postgres_connection_url(url)?;
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
let tz = iana_time_zone::get_timezone().unwrap_or_else(|_| "UTC".to_string());
|
||||
|
||||
super::with_connection_timeout("PostgreSQL", super::connection_timeout(), async {
|
||||
let pg_config =
|
||||
tokio_postgres::Config::from_str(url).map_err(|e| format!("Invalid PostgreSQL connection URL: {e}"))?;
|
||||
let pg_config = tokio_postgres::Config::from_str(&postgres_url.url)
|
||||
.map_err(|e| format!("Invalid PostgreSQL connection URL: {e}"))?;
|
||||
|
||||
let mgr_config = ManagerConfig { recycling_method: RecyclingMethod::Fast };
|
||||
let tls_config = postgres_tls_config(&pg_config);
|
||||
let tls_config = postgres_tls_config(
|
||||
&pg_config,
|
||||
&postgres_url.ssl_files,
|
||||
postgres_url.accepts_invalid_certs,
|
||||
postgres_url.verifies_hostname,
|
||||
)?;
|
||||
let mgr = deadpool_postgres::Manager::from_config(
|
||||
pg_config.clone(),
|
||||
tokio_postgres_rustls::MakeRustlsConnect::new(tls_config),
|
||||
@@ -357,18 +366,182 @@ pub async fn connect(url: &str) -> Result<Pool, String> {
|
||||
.await
|
||||
}
|
||||
|
||||
fn postgres_tls_config(pg_config: &tokio_postgres::Config) -> rustls::ClientConfig {
|
||||
if postgres_sslmode_accepts_invalid_certs(pg_config.get_ssl_mode()) {
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
return rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoPostgresCertVerification { provider }))
|
||||
.with_no_client_auth();
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
struct PostgresSslFiles {
|
||||
sslcert: Option<String>,
|
||||
sslkey: Option<String>,
|
||||
sslrootcert: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PostgresConnectionUrl {
|
||||
url: String,
|
||||
ssl_files: PostgresSslFiles,
|
||||
accepts_invalid_certs: bool,
|
||||
verifies_hostname: bool,
|
||||
}
|
||||
|
||||
fn postgres_connection_url(url: &str) -> Result<PostgresConnectionUrl, String> {
|
||||
let Some(query_start) = url.find('?') else {
|
||||
let pg_config =
|
||||
tokio_postgres::Config::from_str(url).map_err(|e| format!("Invalid PostgreSQL connection URL: {e}"))?;
|
||||
return Ok(PostgresConnectionUrl {
|
||||
url: url.to_string(),
|
||||
ssl_files: PostgresSslFiles::default(),
|
||||
accepts_invalid_certs: postgres_sslmode_accepts_invalid_certs(pg_config.get_ssl_mode()),
|
||||
verifies_hostname: false,
|
||||
});
|
||||
};
|
||||
|
||||
let prefix = &url[..query_start];
|
||||
let suffix = &url[query_start + 1..];
|
||||
let (query_string, fragment) = suffix.split_once('#').map_or((suffix, ""), |(query, fragment)| (query, fragment));
|
||||
let mut ssl_files = PostgresSslFiles::default();
|
||||
let mut kept_params = Vec::new();
|
||||
let mut accepts_invalid_certs = true;
|
||||
let mut verifies_hostname = false;
|
||||
|
||||
for param in query_string.split('&') {
|
||||
if param.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((key, value)) = param.split_once('=') else {
|
||||
kept_params.push(param.to_string());
|
||||
continue;
|
||||
};
|
||||
|
||||
if key.eq_ignore_ascii_case("sslcert")
|
||||
|| key.eq_ignore_ascii_case("sslkey")
|
||||
|| key.eq_ignore_ascii_case("sslrootcert")
|
||||
{
|
||||
let decoded = percent_decode_str(value)
|
||||
.decode_utf8()
|
||||
.map_err(|_| format!("Invalid URL encoding in {key}"))?
|
||||
.into_owned();
|
||||
validate_file_path(&decoded, |_| false).map_err(|e| format!("{key}: {e}"))?;
|
||||
|
||||
if key.eq_ignore_ascii_case("sslcert") {
|
||||
ssl_files.sslcert = Some(decoded);
|
||||
} else if key.eq_ignore_ascii_case("sslkey") {
|
||||
ssl_files.sslkey = Some(decoded);
|
||||
} else {
|
||||
ssl_files.sslrootcert = Some(decoded);
|
||||
}
|
||||
} else if key.eq_ignore_ascii_case("sslmode") {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"verify-ca" => {
|
||||
accepts_invalid_certs = false;
|
||||
kept_params.push("sslmode=require".to_string());
|
||||
}
|
||||
"verify-full" | "verify_identity" | "verify-identity" => {
|
||||
accepts_invalid_certs = false;
|
||||
verifies_hostname = true;
|
||||
kept_params.push("sslmode=require".to_string());
|
||||
}
|
||||
"disable" => {
|
||||
accepts_invalid_certs = false;
|
||||
kept_params.push(param.to_string());
|
||||
}
|
||||
"prefer" | "require" => {
|
||||
accepts_invalid_certs = true;
|
||||
kept_params.push(param.to_string());
|
||||
}
|
||||
_ => kept_params.push(param.to_string()),
|
||||
}
|
||||
} else {
|
||||
kept_params.push(param.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut sanitized_url = prefix.to_string();
|
||||
if !kept_params.is_empty() {
|
||||
sanitized_url.push('?');
|
||||
sanitized_url.push_str(&kept_params.join("&"));
|
||||
}
|
||||
if !fragment.is_empty() {
|
||||
sanitized_url.push('#');
|
||||
sanitized_url.push_str(fragment);
|
||||
}
|
||||
|
||||
Ok(PostgresConnectionUrl { url: sanitized_url, ssl_files, accepts_invalid_certs, verifies_hostname })
|
||||
}
|
||||
|
||||
fn postgres_tls_config(
|
||||
pg_config: &tokio_postgres::Config,
|
||||
ssl_files: &PostgresSslFiles,
|
||||
accepts_invalid_certs: bool,
|
||||
verifies_hostname: bool,
|
||||
) -> Result<rustls::ClientConfig, String> {
|
||||
if pg_config.get_ssl_mode() != SslMode::Disable && accepts_invalid_certs {
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
let builder = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoPostgresCertVerification { provider }));
|
||||
return postgres_tls_client_auth(builder, ssl_files);
|
||||
}
|
||||
|
||||
let root_store = postgres_root_cert_store(ssl_files)?;
|
||||
let builder = if verifies_hostname {
|
||||
rustls::ClientConfig::builder().with_root_certificates(root_store)
|
||||
} else {
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
rustls::ClientConfig::builder().dangerous().with_custom_certificate_verifier(Arc::new(
|
||||
PostgresCaOnlyCertVerification { provider, roots: Arc::new(root_store) },
|
||||
))
|
||||
};
|
||||
postgres_tls_client_auth(builder, ssl_files)
|
||||
}
|
||||
|
||||
fn postgres_root_cert_store(ssl_files: &PostgresSslFiles) -> Result<rustls::RootCertStore, String> {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
rustls::ClientConfig::builder().with_root_certificates(root_store).with_no_client_auth()
|
||||
if let Some(path) = ssl_files.sslrootcert.as_deref() {
|
||||
let certs = read_postgres_pem_certs("sslrootcert", path)?;
|
||||
let (valid_count, _) = root_store.add_parsable_certificates(certs);
|
||||
if valid_count == 0 {
|
||||
return Err(format!("sslrootcert: no valid CA certificates found in {path}"));
|
||||
}
|
||||
} else {
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
}
|
||||
Ok(root_store)
|
||||
}
|
||||
|
||||
fn postgres_tls_client_auth(
|
||||
builder: rustls::ConfigBuilder<rustls::ClientConfig, rustls::client::WantsClientCert>,
|
||||
ssl_files: &PostgresSslFiles,
|
||||
) -> Result<rustls::ClientConfig, String> {
|
||||
match (ssl_files.sslcert.as_deref(), ssl_files.sslkey.as_deref()) {
|
||||
(Some(cert_path), Some(key_path)) => {
|
||||
let certs = read_postgres_pem_certs("sslcert", cert_path)?;
|
||||
if certs.is_empty() {
|
||||
return Err(format!("sslcert: no certificates found in {cert_path}"));
|
||||
}
|
||||
let private_key = read_postgres_private_key(key_path)?;
|
||||
builder
|
||||
.with_client_auth_cert(certs, private_key)
|
||||
.map_err(|e| format!("PostgreSQL client certificate/key mismatch or invalid key: {e}"))
|
||||
}
|
||||
(Some(_), None) => Err("PostgreSQL sslcert requires sslkey".to_string()),
|
||||
(None, Some(_)) => Err("PostgreSQL sslkey requires sslcert".to_string()),
|
||||
(None, None) => Ok(builder.with_no_client_auth()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_postgres_pem_certs(label: &str, path: &str) -> Result<Vec<CertificateDer<'static>>, String> {
|
||||
let file = File::open(path).map_err(|e| format!("{label}: failed to open {path}: {e}"))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
rustls_pemfile::certs(&mut reader)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("{label}: failed to read PEM certificates from {path}: {e}"))
|
||||
}
|
||||
|
||||
fn read_postgres_private_key(path: &str) -> Result<PrivateKeyDer<'static>, String> {
|
||||
let file = File::open(path).map_err(|e| format!("sslkey: failed to open {path}: {e}"))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
rustls_pemfile::private_key(&mut reader)
|
||||
.map_err(|e| format!("sslkey: failed to read PEM private key from {path}: {e}"))?
|
||||
.ok_or_else(|| format!("sslkey: no private key found in {path}"))
|
||||
}
|
||||
|
||||
fn postgres_sslmode_accepts_invalid_certs(ssl_mode: SslMode) -> bool {
|
||||
@@ -415,6 +588,55 @@ impl ServerCertVerifier for NoPostgresCertVerification {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PostgresCaOnlyCertVerification {
|
||||
provider: Arc<CryptoProvider>,
|
||||
roots: Arc<rustls::RootCertStore>,
|
||||
}
|
||||
|
||||
impl ServerCertVerifier for PostgresCaOnlyCertVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
let cert = ParsedCertificate::try_from(end_entity)?;
|
||||
verify_server_cert_signed_by_trust_anchor(
|
||||
&cert,
|
||||
&self.roots,
|
||||
intermediates,
|
||||
now,
|
||||
self.provider.signature_verification_algorithms.all,
|
||||
)?;
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
verify_tls12_signature(message, cert, dss, &self.provider.signature_verification_algorithms)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
verify_tls13_signature(message, cert, dss, &self.provider.signature_verification_algorithms)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
self.provider.signature_verification_algorithms.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the user's connection URL already specifies a timezone via
|
||||
/// the `options` parameter so we don't overwrite it with the local timezone.
|
||||
fn pg_url_has_timezone_setting(url: &str) -> bool {
|
||||
@@ -431,27 +653,9 @@ fn pg_url_has_timezone_setting(url: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn validate_postgres_ssl_paths(url: &str) -> Result<(), String> {
|
||||
if let Some(query_start) = url.find('?') {
|
||||
let query_string = &url[query_start + 1..];
|
||||
|
||||
for param in query_string.split('&') {
|
||||
if let Some((key, value)) = param.split_once('=') {
|
||||
match key {
|
||||
"sslcert" | "sslkey" | "sslrootcert" => {
|
||||
let decoded = percent_decode_str(value)
|
||||
.decode_utf8()
|
||||
.map_err(|_| format!("Invalid URL encoding in {key}"))?;
|
||||
|
||||
validate_file_path(&decoded, |_| false).map_err(|e| format!("{key}: {e}"))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
postgres_connection_url(url).map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn list_databases(pool: &Pool) -> Result<Vec<DatabaseInfo>, String> {
|
||||
@@ -1023,6 +1227,60 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_connection_url_strips_ssl_file_params_before_driver_parse() {
|
||||
let dir = std::env::temp_dir();
|
||||
let cert = dir.join(format!("dbx-postgres-cert-{}.pem", std::process::id()));
|
||||
let key = dir.join(format!("dbx-postgres-key-{}.pem", std::process::id()));
|
||||
let root = dir.join(format!("dbx-postgres-root-{}.pem", std::process::id()));
|
||||
std::fs::write(&cert, "not a real cert").unwrap();
|
||||
std::fs::write(&key, "not a real key").unwrap();
|
||||
std::fs::write(&root, "not a real root").unwrap();
|
||||
|
||||
let url = format!(
|
||||
"postgres://localhost/db?sslmode=verify-full&sslcert={}&sslkey={}&sslrootcert={}&application_name=dbx",
|
||||
cert.display(),
|
||||
key.display(),
|
||||
root.display()
|
||||
);
|
||||
let parsed = postgres_connection_url(&url).unwrap();
|
||||
|
||||
assert_eq!(parsed.url, "postgres://localhost/db?sslmode=require&application_name=dbx");
|
||||
assert_eq!(parsed.ssl_files.sslcert.as_deref(), Some(cert.to_str().unwrap()));
|
||||
assert_eq!(parsed.ssl_files.sslkey.as_deref(), Some(key.to_str().unwrap()));
|
||||
assert_eq!(parsed.ssl_files.sslrootcert.as_deref(), Some(root.to_str().unwrap()));
|
||||
assert!(!parsed.accepts_invalid_certs);
|
||||
assert!(parsed.verifies_hostname);
|
||||
tokio_postgres::Config::from_str(&parsed.url).unwrap();
|
||||
|
||||
let _ = std::fs::remove_file(cert);
|
||||
let _ = std::fs::remove_file(key);
|
||||
let _ = std::fs::remove_file(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_connection_url_keeps_verify_ca_ca_only_semantics() {
|
||||
let parsed = postgres_connection_url("postgres://localhost/db?sslmode=verify-ca").unwrap();
|
||||
|
||||
assert_eq!(parsed.url, "postgres://localhost/db?sslmode=require");
|
||||
assert!(!parsed.accepts_invalid_certs);
|
||||
assert!(!parsed.verifies_hostname);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_tls_rejects_unpaired_client_cert_and_key() {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let pg_config = tokio_postgres::Config::from_str("postgres://localhost/db?sslmode=require").unwrap();
|
||||
let ssl_files =
|
||||
PostgresSslFiles { sslcert: Some("/tmp/client.crt".to_string()), sslkey: None, sslrootcert: None };
|
||||
|
||||
let error = match postgres_tls_config(&pg_config, &ssl_files, true, false) {
|
||||
Ok(_) => panic!("expected missing sslkey to fail"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error.contains("sslkey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_tls_accepts_invalid_certs_for_require_sslmode() {
|
||||
let pg_config = tokio_postgres::Config::from_str("postgres://localhost/db?sslmode=require").unwrap();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#[tokio::test]
|
||||
#[ignore = "requires the remote DBX mTLS smoke-test containers and local client certificates"]
|
||||
async fn live_mysql_mtls_connection_succeeds() {
|
||||
let url = std::env::var("DBX_LIVE_MYSQL_MTLS_URL").expect("DBX_LIVE_MYSQL_MTLS_URL");
|
||||
let ca = std::env::var("DBX_LIVE_MYSQL_MTLS_CA").ok();
|
||||
|
||||
let pool = dbx_core::db::mysql::connect_with_ca_cert(&url, ca.as_deref()).await.unwrap();
|
||||
let result =
|
||||
dbx_core::db::mysql::execute_query(&pool, "SELECT label FROM mtls_smoke WHERE id = 1", false).await.unwrap();
|
||||
|
||||
assert_eq!(result.rows[0][0], serde_json::json!("mysql mtls ok"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires the remote DBX mTLS smoke-test containers and local client certificates"]
|
||||
async fn live_postgres_mtls_connection_succeeds() {
|
||||
let url = std::env::var("DBX_LIVE_POSTGRES_MTLS_URL").expect("DBX_LIVE_POSTGRES_MTLS_URL");
|
||||
|
||||
let pool = dbx_core::db::postgres::connect(&url).await.unwrap();
|
||||
let result =
|
||||
dbx_core::db::postgres::execute_query(&pool, "SELECT label FROM mtls_smoke WHERE id = 1").await.unwrap();
|
||||
|
||||
assert_eq!(result.rows[0][0], serde_json::json!("postgres mtls ok"));
|
||||
}
|
||||
@@ -17,8 +17,37 @@ test("connection dialog exposes generic TLS controls for supported database type
|
||||
});
|
||||
|
||||
test("connection dialog exposes CA certificate path for native MySQL TLS", () => {
|
||||
assert.match(source, /const supportsCaCertificatePath = computed/);
|
||||
assert.match(source, /const supportsMysqlTlsOptions = computed/);
|
||||
assert.match(source, /form\.value\.db_type === "mysql"/);
|
||||
assert.match(source, /v-if="supportsCaCertificatePath"/);
|
||||
assert.match(source, /const mysqlTlsMode = computed/);
|
||||
assert.match(source, /const mysqlClientCertPath = computed/);
|
||||
assert.match(source, /const mysqlClientKeyPath = computed/);
|
||||
assert.match(source, /v-model="mysqlTlsMode"/);
|
||||
assert.match(source, /v-model="form\.ca_cert_path"/);
|
||||
assert.match(source, /v-model="mysqlClientCertPath"/);
|
||||
assert.match(source, /v-model="mysqlClientKeyPath"/);
|
||||
assert.match(source, /setUrlParam\(next, "ssl-cert"/);
|
||||
assert.match(source, /setUrlParam\(next, "ssl-key"/);
|
||||
assert.match(source, /const bareMysqlProfiles = new Set/);
|
||||
assert.match(source, /"oceanbase"/);
|
||||
assert.match(source, /"doris"/);
|
||||
assert.match(source, /"starrocks"/);
|
||||
assert.match(source, /"selectdb"/);
|
||||
});
|
||||
|
||||
test("connection dialog exposes PostgreSQL TLS certificate controls", () => {
|
||||
assert.match(source, /const nativePostgresTlsDatabaseTypes = new Set<DatabaseType>/);
|
||||
assert.match(source, /const supportsPostgresTlsOptions = computed/);
|
||||
assert.match(source, /"postgres"/);
|
||||
assert.match(source, /"redshift"/);
|
||||
assert.match(source, /"gaussdb"/);
|
||||
assert.match(source, /"opengauss"/);
|
||||
assert.match(source, /const postgresTlsMode = computed/);
|
||||
assert.match(source, /v-model="postgresTlsMode"/);
|
||||
assert.match(source, /v-model="postgresRootCertPath"/);
|
||||
assert.match(source, /v-model="postgresClientCertPath"/);
|
||||
assert.match(source, /v-model="postgresClientKeyPath"/);
|
||||
assert.match(source, /setUrlParam\(form\.value\.url_params, "sslrootcert"/);
|
||||
assert.match(source, /setUrlParam\(form\.value\.url_params, "sslcert"/);
|
||||
assert.match(source, /setUrlParam\(form\.value\.url_params, "sslkey"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user