fix(import): import Navicat SSH tunnel settings
This commit is contained in:
parent
b276c0c646
commit
b7f1820e12
|
|
@ -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([[], [], []]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue