Skip to content

Commit 64ac771

Browse files
authored
Merge pull request #1676 from jumpserver/pr@dev@feat_change_mariadb_protocol
fix: use MySQL protocol for MariaDB client connections
2 parents 054b27f + 96fb314 commit 64ac771

7 files changed

Lines changed: 171 additions & 12 deletions

File tree

‎electron/src/apps/local-app-launcher.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,8 @@ export class LocalApplicationLauncher {
258258
throw new Error("local client payload is missing required connection fields");
259259
}
260260
const application = await this.resolveApplication(payload);
261+
// Preserve MariaDB application preferences, but use the MySQL driver on Magnus's shared port.
262+
if (payload.protocol === "mariadb") payload.protocol = "mysql";
261263
if (application.launch_type === "file") {
262264
if (!payload.file?.content) throw new Error("local client payload is missing connection file content");
263265
electronLog.info(`launch ${payload.protocol} connection file via ${application.display_name || "file handler"}`);

‎electron/tests/application-config.test.ts‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "node:assert/strict";
2-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
2+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
55
import test from "node:test";
@@ -281,6 +281,74 @@ test("launches RDP connection files without endpoint fields", async () => {
281281
assert.deepEqual(launchedPayload, payload);
282282
});
283283

284+
for (const [platform, plugin, expected] of [
285+
["macos", "dbeaver", "driver=mysql"],
286+
["linux", "dbeaver", "driver=mysql"],
287+
["windows", "dbeaver", "driver=mysql"],
288+
["windows", "navicat17", "navicat://conn.mysql?"],
289+
["macos", "terminal-db", "mysql -u token-id"],
290+
["linux", "terminal-db", "mysql -u token-id"],
291+
["demo", "tablepro-uploadable", "mysql"]
292+
]) {
293+
test(`${platform}/${plugin} uses MySQL for MariaDB without changing application selection`, async () => {
294+
const directory = platform === "demo" ? plugin : `${platform}.${plugin}`;
295+
const config = JSON.parse(
296+
await readFile(path.join(projectRoot, "plugins", platform, directory, "connect.json"), "utf8")
297+
);
298+
const application = {
299+
name: plugin,
300+
protocol: ["mariadb", "mysql"],
301+
is_set: true,
302+
// Only the MariaDB preference is enabled; normalization must happen after selection.
303+
enabled_protocols: ["mariadb"],
304+
match_first: ["mariadb"],
305+
launch_type: config.launch.type,
306+
launch_driver: config.launch.driver,
307+
arg_format: config.launch.template,
308+
protocol_templates: config.launch.protocol_templates
309+
};
310+
const launcher = new LocalApplicationLauncher(
311+
{ isPackaged: false },
312+
projectRoot,
313+
{ getConfig: async () => ({ databases: [application] }) },
314+
null
315+
);
316+
const payload = {
317+
protocol: "mariadb",
318+
name: "测试数据库",
319+
endpoint: { host: "gateway.example.com", port: 5525 },
320+
token: { id: "token-id", value: "secret", protocol: "mariadb" },
321+
asset: { info: { db_name: "app" } }
322+
};
323+
let launches = 0;
324+
const checkArgs = async (selected, args) => {
325+
assert.equal(selected, application);
326+
assert.ok(args.includes(expected), args);
327+
assert.ok(args.includes("gateway.example.com"), args);
328+
assert.ok(args.includes("5525"), args);
329+
assert.ok(!args.includes("mariadb"), args);
330+
launches++;
331+
};
332+
launcher.launchExecutable = checkArgs;
333+
launcher.launchTerminal = checkArgs;
334+
launcher.launchScript = async (selected, received, values) => {
335+
assert.equal(selected, application);
336+
assert.deepEqual(received, { ...payload, protocol: "mysql", ...(received.client ? { client: plugin } : {}) });
337+
assert.equal(values.protocol, "mysql");
338+
assert.equal(values.dbeaver_protocol, "mysql");
339+
assert.equal(values.dbname, "app");
340+
launches++;
341+
};
342+
for (const client of [undefined, plugin]) {
343+
const url = `jms2://${Buffer.from(JSON.stringify({ ...payload, client })).toString("base64")}`;
344+
await launcher.launch(url);
345+
}
346+
assert.equal(launches, 2);
347+
assert.equal(payload.protocol, "mariadb");
348+
assert.deepEqual(application.enabled_protocols, ["mariadb"]);
349+
});
350+
}
351+
284352
test("normalizes duplicate and hidden system font families", () => {
285353
assert.deepEqual(systemFontInternals.normalizeFamilies(["Menlo", " .Hidden ", "Menlo", "SF Mono", ""]), [
286354
"Menlo",

‎ui/composables/useAssetAction.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,59 @@ describe("opening assets in local applications", () => {
280280
expect(mocks.setConnectionTokenReusable).not.toHaveBeenCalled();
281281
});
282282

283+
it.each([false, true])("launches MariaDB with client-only compatibility on desktop=%s", async (desktop) => {
284+
vi.stubGlobal("isDesktopRuntime", () => desktop);
285+
const dbMethod = { value: "db_client", type: "native", component: "magnus", disabled: false };
286+
vi.stubGlobal("useConnectMethods", () => ({
287+
fetchConnectMethods: async () => ({ mariadb: [dbMethod] }),
288+
getMethodsForProtocol: async () => [dbMethod]
289+
}));
290+
const dbPayload = {
291+
...payload,
292+
protocol: "mariadb",
293+
name: "测试数据库",
294+
endpoint: { host: "gateway.example.com", port: 5525 },
295+
asset: { info: { db_name: "app" } },
296+
token: { ...payload.token, protocol: "mariadb" }
297+
};
298+
const url = `jms2://${btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(dbPayload))))}`;
299+
mocks.getLocalClientUrl.mockResolvedValue({ url });
300+
mocks.createToken.mockResolvedValue(dbPayload.token);
301+
302+
const { ready, failed } = await connect("db_client", "mariadb");
303+
304+
expect(failed).not.toHaveBeenCalled();
305+
expect(mocks.createToken).toHaveBeenCalledWith(
306+
expect.objectContaining({ protocol: "mariadb", connect_method: "db_client" }),
307+
expect.anything()
308+
);
309+
expect(ready.mock.calls[0]?.[0].token.protocol).toBe("mariadb");
310+
const launchedUrl = desktop ? mocks.invoke.mock.calls[0]?.[1].url : mocks.assign.mock.calls[0]?.[0];
311+
const launchedPayload = JSON.parse(
312+
new TextDecoder().decode(Uint8Array.from(atob(launchedUrl.slice(7)), (character) => character.charCodeAt(0)))
313+
);
314+
expect(launchedPayload).toEqual({
315+
...dbPayload,
316+
// Desktop resolves the user's MariaDB application first, then normalizes the driver in the launcher.
317+
protocol: desktop ? "mariadb" : "mysql"
318+
});
319+
});
320+
321+
it("preserves an applet's RDP launch protocol for a MariaDB asset", async () => {
322+
const applet = { value: "dbeaver", type: "applet", component: "razor", disabled: false };
323+
vi.stubGlobal("useConnectMethods", () => ({
324+
fetchConnectMethods: async () => ({ mariadb: [applet] }),
325+
getMethodsForProtocol: async () => [applet]
326+
}));
327+
const url = `jms2://${btoa(JSON.stringify({ ...payload, name: "database", protocol: "rdp" }))}`;
328+
mocks.getLocalClientUrl.mockResolvedValue({ url });
329+
330+
const { failed } = await connect("dbeaver", "mariadb");
331+
332+
expect(failed).not.toHaveBeenCalled();
333+
expect(mocks.assign).toHaveBeenCalledExactlyOnceWith(url);
334+
});
335+
283336
describe("RDP file downloads", () => {
284337
const content = "full address:s:rdp.example\r\nusername:s:用户\r\n";
285338
const rdpMethod = { ...method, value: "mstsc", component: "razor" };

‎ui/composables/useAssetAction.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,15 +110,23 @@ function resolveConnectionErrorDescription(error: unknown, translate: (key: stri
110110
return typeof data.detail === "string" && data.detail ? data.detail : error.message || `HTTP ${error.status}`;
111111
}
112112

113-
const withLocalClientName = (url: string, clientName?: string) => {
114-
if (!clientName || !url.startsWith("jms2://")) return url;
113+
const withLocalClientOptions = (
114+
url: string,
115+
{ clientName, mysqlForMariaDB }: { clientName?: string; mysqlForMariaDB?: boolean }
116+
) => {
117+
if ((!clientName && !mysqlForMariaDB) || !url.startsWith("jms2://")) return url;
115118

116119
try {
117120
const decoded = Uint8Array.from(atob(url.slice("jms2://".length)), (character) => character.charCodeAt(0));
118121
const parsed: unknown = JSON.parse(new TextDecoder().decode(decoded));
119122
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") return url;
120123

121-
const payload = { ...(parsed as Record<string, unknown>), client: clientName };
124+
const payload = { ...(parsed as Record<string, unknown>) };
125+
const useMySQL = mysqlForMariaDB && payload.protocol === "mariadb";
126+
if (!clientName && !useMySQL) return url;
127+
if (clientName) payload.client = clientName;
128+
// Only change the client driver, never the Core token or an applet's RDP protocol.
129+
if (useMySQL) payload.protocol = "mysql";
122130
const encoded = new TextEncoder().encode(JSON.stringify(payload));
123131
return `jms2://${btoa(String.fromCharCode(...encoded))}`;
124132
} catch {
@@ -601,12 +609,12 @@ export const useAssetAction = () => {
601609
};
602610
if (isDesktopRuntime()) {
603611
await desktopInvoke("pull_up", {
604-
url: withLocalClientName(localClientUrl, nativeApp.clientName)
612+
url: withLocalClientOptions(localClientUrl, { clientName: nativeApp.clientName })
605613
});
606614
meta?.onSessionReady?.(payload);
607615
} else {
608616
meta?.onSessionReady?.(payload);
609-
window.location.assign(localClientUrl);
617+
window.location.assign(withLocalClientOptions(localClientUrl, { mysqlForMariaDB: true }));
610618
}
611619
return;
612620
}

‎ui/shared/connectors/GuideSessionSurface.vue‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { TokenResponse } from "~/types";
44
55
import { writeText } from "clipboard-polyfill";
66
import { getPublicSettings, getUserProfile, setConnectionTokenReusable } from "~/composables/useApiRequest";
7-
import { getGuideConnectCommand } from "./guideCommand";
7+
import { getGuideClientProtocol, getGuideConnectCommand } from "./guideCommand";
88
import { getDirectSshCommand } from "./sshGuide";
99
import {
1010
applyConnectionTokenReuse,
@@ -41,6 +41,7 @@ const reusable = ref(false);
4141
const reusableUpdating = ref(false);
4242
4343
const protocol = computed(() => (token.value?.protocol || props.tab.protocol || "").toLowerCase());
44+
const clientProtocol = computed(() => getGuideClientProtocol(protocol.value));
4445
const showReusable = computed(() => shouldShowConnectionTokenReuse(connectionTokenReusable.value, token.value?.id));
4546
const showDatabaseHelp = computed(() => isDatabaseGuideProtocol(protocol.value));
4647
const host = computed(() => String(endpoint.value.host || ""));
@@ -81,7 +82,7 @@ const rows = computed(() => {
8182
...(databaseProtocols.has(protocol.value)
8283
? [{ name: "database", label: t("ConnectionGuide.Database"), value: database.value }]
8384
: []),
84-
{ name: "protocol", label: t("ConnectionGuide.Protocol"), value: protocol.value },
85+
{ name: "protocol", label: t("ConnectionGuide.Protocol"), value: clientProtocol.value },
8586
{ name: "date_expired", label: t("ConnectionGuide.ExpireTime"), value: token.value.date_expired }
8687
];
8788
@@ -218,7 +219,7 @@ onMounted(async () => {
218219
<h2 class="truncate text-base font-semibold text-[var(--app-fg)]">
219220
{{ t("ConnectionGuide.Title") }}
220221
</h2>
221-
<UBadge :label="protocol.toUpperCase()" color="neutral" variant="outline" size="sm" />
222+
<UBadge :label="clientProtocol.toUpperCase()" color="neutral" variant="outline" size="sm" />
222223
</div>
223224
<p class="mt-0.5 truncate text-xs text-[var(--app-muted)]">
224225
{{ assetName }}

‎ui/shared/connectors/guideCommand.test.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { getGuideConnectCommand } from "./guideCommand";
2+
import { getGuideClientProtocol, getGuideConnectCommand } from "./guideCommand";
33

44
const database = {
55
id: "token-id",
@@ -9,7 +9,29 @@ const database = {
99
database: "app"
1010
};
1111

12+
describe("connection guide client protocol", () => {
13+
it.each(["mariadb", "MariaDB", " MARIADB "])("presents %s as mysql", (protocol) => {
14+
expect(getGuideClientProtocol(protocol)).toBe("mysql");
15+
});
16+
17+
it.each(["mysql", "postgresql", "redis", "oracle", "sqlserver", "mongodb", "ssh", "vnc", "", "unknown"])(
18+
"preserves other protocols (%s)",
19+
(protocol) => {
20+
expect(getGuideClientProtocol(protocol)).toBe(protocol);
21+
}
22+
);
23+
});
24+
1225
describe("connection guide CLI", () => {
26+
it.each(["mariadb", "MariaDB", " MARIADB "])(
27+
"uses the MySQL client for %s without changing connection details",
28+
(protocol) => {
29+
const connection = { ...database, protocol, port: "5525" };
30+
expect(getGuideConnectCommand(connection)).toBe("mysql -u token-id -ps3cret -h gateway.example.com -P 5525 app");
31+
expect(connection.protocol).toBe(protocol);
32+
}
33+
);
34+
1335
it("matches the v4 MongoDB client URI including auth and load-balancer flags", () => {
1436
expect(
1537
getGuideConnectCommand({

‎ui/shared/connectors/guideCommand.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
// Client-facing only: retain the original asset/token protocol for backend routing.
2+
export function getGuideClientProtocol(protocol: string) {
3+
const normalized = protocol.trim().toLowerCase();
4+
return normalized === "mariadb" ? "mysql" : normalized;
5+
}
6+
17
export function getGuideConnectCommand(input: {
28
protocol: string;
39
id: string;
@@ -9,13 +15,12 @@ export function getGuideConnectCommand(input: {
915
}) {
1016
const { protocol, id, secret, host, port, database } = input;
1117

12-
switch (protocol) {
18+
switch (getGuideClientProtocol(protocol)) {
1319
case "ssh":
1420
return `ssh JMS-${id}@${host}${port === "22" ? "" : ` -p ${port}`}`;
1521
case "vnc":
1622
return `vncviewer -UserName=${id} ${host}:${port || "5900"}`;
1723
case "mysql":
18-
case "mariadb":
1924
return `mysql -u ${id} -p${secret} -h ${host} -P ${port} ${database}`;
2025
case "postgresql":
2126
return `psql "user=${id} password=${secret} host=${host} dbname=${database} port=${port}"`;

0 commit comments

Comments
 (0)