fix(import): import Navicat SSH tunnel settings

This commit is contained in:
t8y2 2026-07-22 12:53:44 +08:00
parent b276c0c646
commit b7f1820e12
2 changed files with 124 additions and 2 deletions

View File

@ -39,6 +39,16 @@ function parseAttributes(source: string) {
return Array.from(source.matchAll(/([^\s=]+)="([^"]*)"/g)).map((match) => ({ name: match[1] || "", value: match[2] || "" }));
}
async function encryptNavicatPassword(value: string) {
const key = new TextEncoder().encode("libcckeylibcckey");
const iv = new TextEncoder().encode("libcciv libcciv ");
const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "AES-CBC" }, false, ["encrypt"]);
const encrypted = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-CBC", iv }, cryptoKey, new TextEncoder().encode(value)));
return Array.from(encrypted, (byte) => byte.toString(16).padStart(2, "0"))
.join("")
.toUpperCase();
}
if (!globalThis.DOMParser) {
globalThis.DOMParser = TestDOMParser as typeof DOMParser;
}
@ -83,6 +93,7 @@ describe("parseNavicatConnections", () => {
expect(connection?.host).toBe("db.example.test");
expect(connection?.database).toBe("appdb");
expect(connection?.port).toBe(15432);
expect(connection?.transport_layers).toEqual([]);
});
it("prefers Navicat ConnType over Redis deployment Type", async () => {
@ -97,4 +108,80 @@ describe("parseNavicatConnections", () => {
expect(connection?.port).toBe(16379);
expect(connection?.username).toBe("default");
});
it("imports password-authenticated SSH tunnels and decrypts both passwords", async () => {
const databasePassword = await encryptNavicatPassword("database-secret");
const sshPassword = await encryptNavicatPassword("ssh-secret");
const [connection] = await parseNavicatConnections(`<Connections>
<Connection ConnType="MYSQL" ConnectionName="mysql-over-ssh" Host="db.internal" Port="3306" UserName="dbuser" Password="${databasePassword}" SSH="true" SSH_Host="bastion.example.test" SSH_Port="2202" SSH_UserName="sshuser" SSH_AuthenMethod="PASSWORD" SSH_Password="${sshPassword}" />
</Connections>`);
expect(connection?.password).toBe("database-secret");
expect(connection?.transport_layers).toEqual([
expect.objectContaining({
type: "ssh",
enabled: true,
host: "bastion.example.test",
port: 2202,
user: "sshuser",
password: "ssh-secret",
key_path: "",
key_passphrase: "",
auth_method: "password",
}),
]);
});
it("imports key-authenticated SSH field variants with the default port", async () => {
const keyPassphrase = await encryptNavicatPassword("key-secret");
const [connection] = await parseNavicatConnections(`<Connections>
<Connection ConnType="POSTGRESQL" ConnectionName="variant-ssh" Host="db.internal" UseSSHTunnel="1" SSHTunnelHost="jump.example.test" SSHTunnelUsername="deploy" SSHAuthenticationMethod="PUBLIC_KEY" SSHIdentityFile="~/.ssh/id_ed25519" SSHKeyPassphrase="${keyPassphrase}" />
</Connections>`);
expect(connection?.transport_layers).toEqual([
expect.objectContaining({
type: "ssh",
enabled: true,
host: "jump.example.test",
port: 22,
user: "deploy",
password: "",
key_path: "~/.ssh/id_ed25519",
key_passphrase: "key-secret",
auth_method: "key",
}),
]);
});
it("imports standard Navicat private-key SSH fields", async () => {
const keyPassphrase = await encryptNavicatPassword("standard-key-secret");
const [connection] = await parseNavicatConnections(`<Connections>
<Connection ConnType="MYSQL" ConnectionName="standard-key-ssh" Host="db.internal" SSH="true" SSH_Host="bastion.example.test" SSH_Port="2222" SSH_UserName="deploy" SSH_AuthenMethod="PUBLICKEY" SSH_PrivateKey="C:\\Users\\deploy\\.ssh\\id_rsa" SSH_Passphrase="${keyPassphrase}" />
</Connections>`);
expect(connection?.transport_layers).toEqual([
expect.objectContaining({
type: "ssh",
enabled: true,
host: "bastion.example.test",
port: 2222,
user: "deploy",
password: "",
key_path: "C:\\Users\\deploy\\.ssh\\id_rsa",
key_passphrase: "standard-key-secret",
auth_method: "key",
}),
]);
});
it("does not create tunnels when SSH is disabled or required fields are missing", async () => {
const connections = await parseNavicatConnections(`<Connections>
<Connection ConnType="MYSQL" ConnectionName="disabled-ssh" Host="db-1.internal" SSH="false" SSH_Host="jump.example.test" SSH_UserName="deploy" />
<Connection ConnType="MYSQL" ConnectionName="missing-ssh-host" Host="db-2.internal" SSH="true" SSH_UserName="deploy" />
<Connection ConnType="MYSQL" ConnectionName="missing-ssh-user" Host="db-3.internal" SSH="true" SSH_Host="jump.example.test" />
</Connections>`);
expect(connections).toHaveLength(3);
expect(connections.map((connection) => connection.transport_layers)).toEqual([[], [], []]);
});
});

View File

@ -1,4 +1,4 @@
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import type { ConnectionConfig, DatabaseType, SshTunnelConfig } from "@/types/database";
import { uuid } from "@/lib/common/utils";
type PartialConnection = Omit<ConnectionConfig, "id">;
@ -97,6 +97,40 @@ async function decryptNavicatPassword(value: string) {
}
}
function parseNavicatPort(value: string, fallback: number) {
const port = Number(value);
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : fallback;
}
async function parseSshTunnel(values: Record<string, string>): Promise<({ type: "ssh" } & SshTunnelConfig) | null> {
const enabled = getAny(values, ["ssh", "useSsh", "sshEnabled", "enableSsh", "useSshTunnel", "sshTunnelEnabled"]);
if (!truthyNavicatFlag(enabled)) return null;
const host = getAny(values, ["sshHost", "sshTunnelHost", "tunnelHost"]);
const user = getAny(values, ["sshUserName", "sshUsername", "sshUser", "sshTunnelUserName", "sshTunnelUsername", "tunnelUserName"]);
// A half-populated tunnel makes an otherwise valid imported connection unusable.
if (!host || !user) return null;
const authValue = normalizeKey(getAny(values, ["sshAuthenMethod", "sshAuthMethod", "sshAuthenticationMethod", "sshAuthentication", "sshAuthType"]));
const keyPath = getAny(values, ["sshPrivateKey", "sshKeyFile", "sshKeyPath", "sshIdentityFile", "sshTunnelPrivateKey"]);
const usesKey = authValue.includes("key") || (!authValue.includes("password") && !!keyPath);
const password = usesKey ? "" : await decryptNavicatPassword(getAny(values, ["sshPassword", "sshTunnelPassword"]));
const keyPassphrase = usesKey ? await decryptNavicatPassword(getAny(values, ["sshPassphrase", "sshKeyPassphrase", "sshPrivateKeyPassphrase"])) : "";
return {
type: "ssh",
id: uuid(),
enabled: true,
host,
port: parseNavicatPort(getAny(values, ["sshPort", "sshTunnelPort", "tunnelPort"]), 22),
user,
password,
key_path: usesKey ? keyPath : "",
key_passphrase: keyPassphrase,
auth_method: usesKey ? "key" : "password",
};
}
function inferProfile(rawType: string, tag: string, port?: number) {
const key = normalizeKey(rawType || tag);
for (const [needle, profile] of Object.entries(typeMap)) {
@ -194,6 +228,7 @@ async function parseConnection(node: ParsedNode): Promise<ConnectionConfig | nul
const keepaliveFlag = getAny(node.values, ["keepAlive", "keepalive", "useKeepAlive", "enableKeepAlive"]);
const keepaliveEnabled = !keepaliveFlag || truthyNavicatFlag(keepaliveFlag);
const keepaliveInterval = Number.isFinite(keepaliveValue) && keepaliveValue > 0 && keepaliveEnabled ? keepaliveValue : 0;
const sshTunnel = await parseSshTunnel(node.values);
const config: PartialConnection = {
name,
@ -207,7 +242,7 @@ async function parseConnection(node: ParsedNode): Promise<ConnectionConfig | nul
password,
database: database || undefined,
color: "",
transport_layers: [],
transport_layers: sshTunnel ? [sshTunnel] : [],
connect_timeout_secs: 10,
query_timeout_secs: 30,
keepalive_interval_secs: keepaliveInterval,