From e88df7827b72ce6a469671d06c1fbb7616f02a29 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 30 Sep 2026 11:56:14 -0500 Subject: [PATCH] fix: prefer ipv4 when resolving ssh host names (#1365) --- plugins/ssh-terminal/src/backend/helpers.ts | 17 +++++++++++--- .../tests/backend/ssh-dns.test.ts | 22 +++++++++++++++++++ src/backend/hosts/ssh-dns.ts | 17 +++++++++++--- src/backend/tests/hosts/ssh-dns.test.ts | 22 +++++++++++++++++++ 4 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 plugins/ssh-terminal/tests/backend/ssh-dns.test.ts diff --git a/plugins/ssh-terminal/src/backend/helpers.ts b/plugins/ssh-terminal/src/backend/helpers.ts index 33ec941ef..fd0b7b16e 100644 --- a/plugins/ssh-terminal/src/backend/helpers.ts +++ b/plugins/ssh-terminal/src/backend/helpers.ts @@ -154,6 +154,17 @@ export function isRetriableDnsError(error: unknown): boolean { ); } +type LookupResult = { address: string; family: number }; + +// Prefer IPv4: ssh2 gets one address and has no fallback, and a name that +// also has an AAAA record often points at a v6 address sshd is not bound to. +export function pickSshAddress(result: LookupResult | LookupResult[]): string { + const list = Array.isArray(result) ? result : [result]; + if (list.length === 0) + throw Object.assign(new Error("No address found"), { code: "ENOTFOUND" }); + return (list.find((entry) => entry.family === 4) ?? list[0]).address; +} + /** Resolves a hostname up front, retrying transient container DNS failures. */ export async function resolveHostForSshConnect( host: string, @@ -168,10 +179,10 @@ export async function resolveHostForSshConnect( for (let attempt = 0; ; attempt += 1) { try { - const result = await lookup(normalized); + const address = pickSshAddress(await lookup(normalized, { all: true })); return { - host: result.address, - resolvedAddress: result.address, + host: address, + resolvedAddress: address, attempts: attempt + 1, }; } catch (error) { diff --git a/plugins/ssh-terminal/tests/backend/ssh-dns.test.ts b/plugins/ssh-terminal/tests/backend/ssh-dns.test.ts new file mode 100644 index 000000000..334c4a517 --- /dev/null +++ b/plugins/ssh-terminal/tests/backend/ssh-dns.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; +import { + pickSshAddress, + resolveHostForSshConnect, +} from "../../src/backend/helpers.js"; + +describe("resolveHostForSshConnect", () => { + it("prefers IPv4 over IPv6", async () => { + const lookup = vi.fn().mockResolvedValue([ + { address: "fe80::1", family: 6 }, + { address: "192.168.1.20", family: 4 }, + ]); + const result = await resolveHostForSshConnect("nas", lookup); + expect(result.host).toBe("192.168.1.20"); + }); + + it("keeps an IPv6-only answer", () => { + expect(pickSshAddress({ address: "2001:db8::5", family: 6 })).toBe( + "2001:db8::5", + ); + }); +}); diff --git a/src/backend/hosts/ssh-dns.ts b/src/backend/hosts/ssh-dns.ts index cc22cbe28..2fae2c5d9 100644 --- a/src/backend/hosts/ssh-dns.ts +++ b/src/backend/hosts/ssh-dns.ts @@ -25,6 +25,17 @@ export function shouldResolveBeforeSshConnect(host: string): boolean { return net.isIP(normalized) === 0; } +type LookupResult = { address: string; family: number }; + +// Prefer IPv4: ssh2 gets one address and has no fallback, and a name that +// also has an AAAA record often points at a v6 address sshd is not bound to. +export function pickSshAddress(result: LookupResult | LookupResult[]): string { + const list = Array.isArray(result) ? result : [result]; + if (list.length === 0) + throw Object.assign(new Error("No address found"), { code: "ENOTFOUND" }); + return (list.find((entry) => entry.family === 4) ?? list[0]).address; +} + export async function resolveHostForSshConnect( host: string, lookup: Lookup = dns.lookup, @@ -38,10 +49,10 @@ export async function resolveHostForSshConnect( for (let attempt = 0; ; attempt += 1) { try { - const result = await lookup(normalized); + const address = pickSshAddress(await lookup(normalized, { all: true })); return { - host: result.address, - resolvedAddress: result.address, + host: address, + resolvedAddress: address, attempts: attempt + 1, }; } catch (error) { diff --git a/src/backend/tests/hosts/ssh-dns.test.ts b/src/backend/tests/hosts/ssh-dns.test.ts index c961d607d..1174ce93b 100644 --- a/src/backend/tests/hosts/ssh-dns.test.ts +++ b/src/backend/tests/hosts/ssh-dns.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { isRetriableDnsError, + pickSshAddress, resolveHostForSshConnect, resolveSshConnectConfigHost, shouldResolveBeforeSshConnect, @@ -76,4 +77,25 @@ describe("SSH DNS resolution", () => { resolvedHost: "10.0.0.6", }); }); + + it("prefers an IPv4 address when a name has both", async () => { + const lookup = vi.fn().mockResolvedValue([ + { address: "::1", family: 6 }, + { address: "10.0.0.7", family: 4 }, + ]); + + await expect(resolveHostForSshConnect("box", lookup)).resolves.toEqual({ + host: "10.0.0.7", + resolvedAddress: "10.0.0.7", + attempts: 1, + }); + expect(lookup).toHaveBeenCalledWith("box", { all: true }); + }); + + it("falls back to IPv6 when that is all there is", () => { + expect(pickSshAddress([{ address: "2001:db8::2", family: 6 }])).toBe( + "2001:db8::2", + ); + expect(() => pickSshAddress([])).toThrow(); + }); });