feat(zookeeper): support cluster authentication setup

This commit is contained in:
Jevin 2026-08-03 16:58:47 +08:00 committed by GitHub
parent 50cd9a8f95
commit db791b5aed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 242 additions and 3 deletions

View File

@ -35,6 +35,7 @@ import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/con
import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connection/connectionPresentation";
import { h2ConnectionModeForConfig, h2FileJdbcUrlWithPath, h2FilePathFromJdbcUrl, isH2SplitJdbcUrl, type H2ConnectionMode } from "@/lib/database/h2Connection";
import { firstZooKeeperEndpoint, normalizeZooKeeperConnectString } from "@/lib/zookeeper/zookeeperConnection";
import { setZooKeeperAuthScheme, zooKeeperAuthScheme as resolveZooKeeperAuthScheme, type ZooKeeperAuthScheme } from "@/lib/zookeeper/zookeeperConnectionOptions";
import { isLocalFileTypeDb } from "@/lib/connection/connectionFile";
import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedVersion } from "@/lib/mq/mqPinnedVersionOptions";
import { mongodbAuthFailureHint, mongoUrlParam, mongoUrlParamIsTrue, normalizeMongoTlsFormState, setMongoUrlParam, setMongoUrlParamBoolean } from "@/lib/mongo/mongoConnectionOptions";
@ -2662,6 +2663,13 @@ const zookeeperConnectString = computed({
form.value.connection_string = normalizeZooKeeperConnectString(value);
},
});
const zookeeperAuthScheme = computed<ZooKeeperAuthScheme>({
get: () => resolveZooKeeperAuthScheme(form.value.url_params),
set: (scheme) => {
form.value.url_params = setZooKeeperAuthScheme(form.value.url_params, scheme);
resetTestState();
},
});
const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access" && !isCloudflareD1Connection(form.value) && !isH2FileMode.value && !(form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns"));
const shouldShowAgentDriverInstallHint = computed(() => showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile));
const h2DriverMissing = computed(() => form.value.db_type === "h2" && isH2FileMode.value && agentDrivers.value.find((d) => d.db_type === "h2")?.installed !== true);
@ -4877,6 +4885,10 @@ function openExternalUrl(url: string) {
</Tooltip>
</div>
</div>
<div v-if="form.db_type === 'zookeeper'" class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 m-0 text-xs leading-5 text-muted-foreground">{{ t("connection.zookeeperClusterInputHint") }}</p>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.name") }}</Label>
@ -5697,6 +5709,18 @@ function openExternalUrl(url: string) {
</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.zookeeperAuthMethod") }}</Label>
<Select v-model="zookeeperAuthScheme">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="digest">{{ t("connection.zookeeperAuthDigest") }}</SelectItem>
<SelectItem value="sasl_digest">{{ t("connection.zookeeperAuthSaslDigest") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" />

View File

@ -348,7 +348,11 @@ export default {
etcdClientKeyBrowse: "Choose client private key",
etcdClientCertPairRequired: "Client certificate and private key must be provided together.",
zookeeperConnectString: "Connect String",
zookeeperConnectStringHint: "Comma or line separated host:port entries. Leave blank to use the host and port above.",
zookeeperConnectStringHint: "Comma or line separated host:port entries, with an optional shared /chroot. Leave blank to use the host and port above.",
zookeeperClusterInputHint: "For a ZooKeeper ensemble, enter all nodes either in the URL (for example zookeeper://zk-1:2181,zk-2:2181/app) or in Connect String below.",
zookeeperAuthMethod: "Authentication",
zookeeperAuthDigest: "Digest (default)",
zookeeperAuthSaslDigest: "SASL DIGEST-MD5",
zookeeperCreateModePersistent: "Persistent",
zookeeperCreateModeEphemeral: "Ephemeral",
zookeeperCreateModePersistentSequential: "Persistent Sequential",

View File

@ -703,6 +703,10 @@ export default withEnglishFallback({
damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true",
damengJvmOptionsHint: "Una propiedad -Dkey o -Dkey=value por línea. Los valores con espacios no necesitan comillas del shell.",
damengJvmOptionsInvalid: "La línea {line} debe ser una propiedad -Dkey o -Dkey=value sin comillas del shell.",
zookeeperClusterInputHint: "El clúster ZooKeeper puede completarse con todos los nodos en la URL superior (por ejemplo, zookeeper://zk-1:2181,zk-2:2181/app), o en el Connect String a continuación.",
zookeeperAuthMethod: "Método de autenticación",
zookeeperAuthDigest: "Digest (predeterminado)",
zookeeperAuthSaslDigest: "SASL DIGEST-MD5",
},
editor: {
duckdbDraining: "La consulta anterior de DuckDB aún se está deteniendo. Vuelva a intentarlo en breve.",

View File

@ -701,6 +701,10 @@ export default withEnglishFallback({
damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true",
damengJvmOptionsHint: "Una proprietà -Dkey o -Dkey=value per riga. I valori con spazi non richiedono virgolette della shell.",
damengJvmOptionsInvalid: "La riga {line} deve essere una proprietà -Dkey o -Dkey=value senza virgolette della shell.",
zookeeperClusterInputHint: "Il cluster ZooKeeper può essere specificato inserendo tutti i nodi nell'URL superiore (ad esempio zookeeper://zk-1:2181,zk-2:2181/app), oppure nella stringa di connessione sottostante.",
zookeeperAuthMethod: "Metodo di autenticazione",
zookeeperAuthDigest: "Digest (predefinito)",
zookeeperAuthSaslDigest: "SASL DIGEST-MD5",
},
editor: {
duckdbDraining: "La query DuckDB precedente è ancora in fase di arresto. Riprova a breve.",

View File

@ -722,6 +722,10 @@ export default withEnglishFallback({
httpTunnelInvalidOrder: "{hop}: HTTP トンネルは最初のトンネル/プロキシレイヤーに配置する必要があります",
httpTunnelInvalidUrl: "{hop}: トンネルスクリプト URL は必須です",
httpTunnelInvalidTimeout: "{hop}: HTTP トンネルのタイムアウト時間は 1 から 300 秒の間で設定してください",
zookeeperClusterInputHint: "ZooKeeperクラスタは、上部のURLにすべてのードを記入zookeeper://zk-1:2181,zk-2:2181/app、または下部のConnect Stringに記入できます。",
zookeeperAuthMethod: "認証方式",
zookeeperAuthDigest: "Digestデフォルト",
zookeeperAuthSaslDigest: "SASL DIGEST-MD5",
},
editor: {
duckdbDraining: "前回の DuckDB クエリはまだ停止処理中です。しばらくしてから再試行してください。",

View File

@ -702,6 +702,10 @@ export default withEnglishFallback({
damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true",
damengJvmOptionsHint: "Uma propriedade -Dkey ou -Dkey=value por linha. Valores com espaços não precisam de aspas do shell.",
damengJvmOptionsInvalid: "A linha {line} deve ser uma propriedade -Dkey ou -Dkey=value sem aspas do shell.",
zookeeperClusterInputHint: "O cluster ZooKeeper pode ser preenchido com todos os nós na URL acima (por exemplo, zookeeper://zk-1:2181,zk-2:2181/app), ou também na Connect String abaixo.",
zookeeperAuthMethod: "Método de autenticação",
zookeeperAuthDigest: "Digest (padrão)",
zookeeperAuthSaslDigest: "SASL DIGEST-MD5",
},
editor: {
duckdbDraining: "A consulta anterior do DuckDB ainda está sendo interrompida. Tente novamente em breve.",

View File

@ -350,7 +350,11 @@ export default withEnglishFallback({
etcdClientKeyBrowse: "选择客户端私钥",
etcdClientCertPairRequired: "客户端证书和私钥必须一起填写。",
zookeeperConnectString: "Connect String",
zookeeperConnectStringHint: "可用逗号或换行分隔 host:port留空时使用上面的 host 和端口。",
zookeeperConnectStringHint: "可用逗号或换行分隔 host:port并可在末尾填写整个集群共用的 /chroot留空时使用上面的 host 和端口。",
zookeeperClusterInputHint: "ZooKeeper 集群可在上方 URL 中填写全部节点(例如 zookeeper://zk-1:2181,zk-2:2181/app也可在下方 Connect String 中填写。",
zookeeperAuthMethod: "认证方式",
zookeeperAuthDigest: "Digest默认",
zookeeperAuthSaslDigest: "SASL DIGEST-MD5",
zookeeperCreateModePersistent: "持久节点",
zookeeperCreateModeEphemeral: "临时节点",
zookeeperCreateModePersistentSequential: "持久顺序节点",

View File

@ -701,6 +701,10 @@ export default withEnglishFallback({
damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true",
damengJvmOptionsHint: "每行一個 -Dkey 或 -Dkey=value值包含空格時也不要使用 shell 引號。",
damengJvmOptionsInvalid: "第 {line} 行必須是未使用 shell 引號的 -Dkey 或 -Dkey=value 系統屬性。",
zookeeperClusterInputHint: "ZooKeeper 叢集可在上方 URL 中填寫全部節點(例如 zookeeper://zk-1:2181,zk-2:2181/app也可在下方 Connect String 中填寫。",
zookeeperAuthMethod: "認證方式",
zookeeperAuthDigest: "Digest預設",
zookeeperAuthSaslDigest: "SASL DIGEST-MD5",
},
editor: {
duckdbDraining: "上一筆 DuckDB 查詢仍在停止,請稍後重試。",

View File

@ -2,7 +2,56 @@ import { describe, expect, it } from "vitest";
import { parseConnectionUrl } from "@/lib/connection/connectionUrl";
describe("ZooKeeper connection URLs", () => {
it("parses a two-node ensemble and uses the first endpoint for host and port", () => {
const parsed = parseConnectionUrl("zookeeper://zk-1:2181,zk-2:2182");
expect(parsed.host).toBe("zk-1");
expect(parsed.port).toBe(2181);
expect(parsed.connectionString).toBe("zk-1:2181,zk-2:2182");
});
it("parses three nodes and preserves query parameters except name", () => {
const parsed = parseConnectionUrl("zookeeper://zk-1:2181,zk-2:2182,zk-3:2183?session_timeout=30000&name=Production");
expect(parsed.name).toBe("Production");
expect(parsed.urlParams).toBe("session_timeout=30000");
expect(parsed.connectionString).toBe("zk-1:2181,zk-2:2182,zk-3:2183");
});
it("preserves a shared chroot separately from query parameters", () => {
const parsed = parseConnectionUrl("zookeeper://zk-1:2181,zk-2:2181/services/app?connect_timeout=5000");
expect(parsed.connectionString).toBe("zk-1:2181,zk-2:2181/services/app");
expect(parsed.urlParams).toBe("connect_timeout=5000");
});
it("decodes credentials that apply to the ensemble", () => {
const parsed = parseConnectionUrl("zookeeper://dbx%40ops:p%40ss@zk-1:2181,zk-2:2181/app");
expect(parsed.username).toBe("dbx@ops");
expect(parsed.password).toBe("p@ss");
expect(parsed.connectionString).toBe("zk-1:2181,zk-2:2181/app");
});
it("supports bracketed IPv6 endpoints without confusing address colons with separators", () => {
const parsed = parseConnectionUrl("zookeeper://[2001:db8::1]:2181,[2001:db8::2]:2281/app");
expect(parsed.host).toBe("2001:db8::1");
expect(parsed.port).toBe(2181);
expect(parsed.connectionString).toBe("[2001:db8::1]:2181,[2001:db8::2]:2281/app");
});
it("rejects malformed or empty ensemble endpoints", () => {
expect(() => parseConnectionUrl("zookeeper://zk-1:2181,,zk-2:2181")).toThrow("Invalid connection URL");
expect(() => parseConnectionUrl("zookeeper://zk-1:not-a-port,zk-2:2181")).toThrow("Invalid connection URL");
expect(() => parseConnectionUrl("zookeeper://2001:db8::1:2181,zk-2:2181")).toThrow("Invalid connection URL");
});
it("preserves the host:port chroot path as a ZooKeeper connect string", () => {
expect(parseConnectionUrl("zookeeper://zk-main:2181/app").connectionString).toBe("zk-main:2181/app");
const parsed = parseConnectionUrl("zookeeper://zk-main:2181/app");
expect(parsed.host).toBe("zk-main");
expect(parsed.port).toBe(2181);
expect(parsed.connectionString).toBe("zk-main:2181/app");
});
});

View File

@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
const enSource = readFileSync(new URL("../../../i18n/locales/en.ts", import.meta.url), "utf8");
const zhCnSource = readFileSync(new URL("../../../i18n/locales/zh-CN.ts", import.meta.url), "utf8");
const fallbackLocaleSources = ["es", "it", "ja", "ko", "pt-BR", "zh-TW"].map((locale) => readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8"));
describe("ZooKeeper connection dialog", () => {
it("binds the existing Select component to the ZooKeeper auth_scheme helper", () => {
expect(dialogSource).toContain("setZooKeeperAuthScheme");
expect(dialogSource).toContain("resolveZooKeeperAuthScheme");
expect(dialogSource).toContain("const zookeeperAuthScheme = computed<ZooKeeperAuthScheme>");
expect(dialogSource).toContain('<Select v-model="zookeeperAuthScheme">');
expect(dialogSource).toContain('<SelectItem value="digest">');
expect(dialogSource).toContain('<SelectItem value="sasl_digest">');
});
it("uses localized auth labels and tells users both cluster input locations", () => {
for (const source of [enSource, zhCnSource]) {
expect(source).toContain("zookeeperAuthMethod:");
expect(source).toContain("zookeeperAuthDigest:");
expect(source).toContain("zookeeperAuthSaslDigest:");
expect(source).toContain("zookeeperClusterInputHint:");
}
for (const source of fallbackLocaleSources) {
expect(source).toContain("withEnglishFallback");
}
expect(dialogSource).toContain('t("connection.zookeeperAuthMethod")');
expect(dialogSource).toContain('t("connection.zookeeperClusterInputHint")');
});
});

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { setZooKeeperAuthScheme, zooKeeperAuthScheme } from "@/lib/zookeeper/zookeeperConnectionOptions";
describe("ZooKeeper connection options", () => {
it("defaults to digest when auth_scheme is absent", () => {
expect(zooKeeperAuthScheme("connect_timeout=10")).toBe("digest");
});
it("reads the supported SASL DIGEST-MD5 scheme", () => {
expect(zooKeeperAuthScheme("connect_timeout=10&auth_scheme=sasl_digest")).toBe("sasl_digest");
});
it("writes sasl_digest without losing unrelated URL parameters", () => {
expect(setZooKeeperAuthScheme("connect_timeout=10&session_timeout=20", "sasl_digest")).toBe("connect_timeout=10&session_timeout=20&auth_scheme=sasl_digest");
});
it("switches back to digest by removing only auth_scheme", () => {
expect(setZooKeeperAuthScheme("connect_timeout=10&auth_scheme=sasl_digest&session_timeout=20", "digest")).toBe("connect_timeout=10&session_timeout=20");
});
it("reads legacy semicolon-separated parameters and case-insensitive keys", () => {
expect(zooKeeperAuthScheme("connect_timeout=10;AUTH_SCHEME=sasl_digest;session_timeout=20")).toBe("sasl_digest");
});
it("removes every auth_scheme alias while preserving unrelated legacy parameters", () => {
expect(setZooKeeperAuthScheme("connect_timeout=10;AUTH_SCHEME=digest&auth_scheme=sasl_digest;session_timeout=20", "digest")).toBe("connect_timeout=10&session_timeout=20");
});
});

View File

@ -171,6 +171,56 @@ function databaseFromPath(pathname: string): string | undefined {
return decodeUrlPart(value.split("/")[0]);
}
function parseZooKeeperUrl(source: string): ParsedConnectionUrl | null {
const match = source.match(/^zookeeper:\/\/([^/?#]+)(\/[^?#]*)?(\?[^#]*)?$/i);
if (!match) return null;
const profile = SCHEME_PROFILES.zookeeper;
const authority = match[1];
const userInfoEnd = authority.lastIndexOf("@");
const userInfo = userInfoEnd >= 0 ? authority.slice(0, userInfoEnd) : "";
const endpointList = userInfoEnd >= 0 ? authority.slice(userInfoEnd + 1) : authority;
const [rawUsername, ...rawPasswordParts] = userInfo.split(":");
const username = userInfo ? decodeUrlPart(rawUsername) : "";
const password = userInfo ? decodeUrlPart(rawPasswordParts.join(":")) : "";
const endpoints = endpointList.split(",").map((endpoint) => endpoint.trim());
if (endpoints.some((endpoint) => !endpoint)) throw new Error("Invalid connection URL");
const normalizedEndpoints = endpoints.map((endpoint) => {
let endpointUrl: URL;
try {
endpointUrl = new URL(`zookeeper://${endpoint}`);
} catch {
throw new Error("Invalid connection URL");
}
if (endpointUrl.username || endpointUrl.password || (endpointUrl.pathname && endpointUrl.pathname !== "/") || endpointUrl.search || endpointUrl.hash) {
throw new Error("Invalid connection URL");
}
const rawHost = endpointUrl.hostname.replace(/^\[(.*)]$/, "$1");
const host = rawHost.includes(":") ? `[${rawHost}]` : rawHost;
const port = endpointUrl.port ? Number(endpointUrl.port) : profile.defaultPort;
return { host: rawHost, port, connectString: `${host}:${port}` };
});
const chroot = match[2] && match[2] !== "/" ? match[2] : "";
const urlParams = (match[3] || "").replace(/^\?/, "");
const name = queryParamValue(urlParams, "name")?.trim();
return {
...(name ? { name } : {}),
dbType: profile.type,
driverProfile: profile.profile,
driverLabel: profile.label,
host: normalizedEndpoints[0].host,
port: normalizedEndpoints[0].port,
username,
password,
database: undefined,
urlParams: stripConnectionNameParam(urlParams),
ssl: false,
connectionString: `${normalizedEndpoints.map((endpoint) => endpoint.connectString).join(",")}${chroot}`,
};
}
function queryParamValue(params: string, key: string): string | undefined {
for (const part of params.split(/[&;]/)) {
if (!part) continue;
@ -524,6 +574,9 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa
const mongoResult = parseMongoUrl(source);
if (mongoResult) return mongoResult;
const zooKeeperResult = parseZooKeeperUrl(source);
if (zooKeeperResult) return zooKeeperResult;
let parsed: URL;
try {
parsed = new URL(source);

View File

@ -0,0 +1,25 @@
export type ZooKeeperAuthScheme = "digest" | "sasl_digest";
const AUTH_SCHEME_PARAM = "auth_scheme";
function parseParams(params?: string): URLSearchParams {
return new URLSearchParams((params || "").trim().replace(/^\?/, "").replace(/;/g, "&"));
}
function isAuthSchemeParam(key: string): boolean {
return key.trim().toLowerCase() === AUTH_SCHEME_PARAM;
}
export function zooKeeperAuthScheme(params?: string): ZooKeeperAuthScheme {
const configured = Array.from(parseParams(params).entries()).find(([key]) => isAuthSchemeParam(key))?.[1];
return configured?.toLowerCase() === "sasl_digest" ? "sasl_digest" : "digest";
}
export function setZooKeeperAuthScheme(params: string | undefined, scheme: ZooKeeperAuthScheme): string {
const parsed = parseParams(params);
for (const key of Array.from(parsed.keys())) {
if (isAuthSchemeParam(key)) parsed.delete(key);
}
if (scheme === "sasl_digest") parsed.set(AUTH_SCHEME_PARAM, scheme);
return parsed.toString();
}