From 1515a8487f987fd8ebf9621b31e422339c2e8b4d Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 8 Oct 2026 05:08:00 +0200 Subject: [PATCH] fix(desktop): fall back across resolved addresses on connect (#3275) --- .../src/main/DesktopOutboundHTTP.test.mjs | 93 +++++++++++++++++++ .../src/main/DesktopOutboundHTTP.ts | 57 ++++++++---- 2 files changed, 131 insertions(+), 19 deletions(-) diff --git a/fluxer_desktop/src/main/DesktopOutboundHTTP.test.mjs b/fluxer_desktop/src/main/DesktopOutboundHTTP.test.mjs index 9e3ecc247..97d651030 100644 --- a/fluxer_desktop/src/main/DesktopOutboundHTTP.test.mjs +++ b/fluxer_desktop/src/main/DesktopOutboundHTTP.test.mjs @@ -678,6 +678,99 @@ describe('DesktopOutboundHTTP origin anchoring', () => { } }); + test('the pinned lookup offers every validated address for happy eyeballs', async () => { + const lookup = createPinnedHostLookup('dual.example', [ + {address: '2606:4700:4700::1111', family: 6}, + {address: '93.184.216.34', family: 4}, + ]); + const all = await new Promise((resolve) => + lookup('dual.example', {all: true}, (error, addresses) => resolve({addresses, error})), + ); + assert.equal(all.error, null); + assert.deepEqual(all.addresses, [ + {address: '2606:4700:4700::1111', family: 6}, + {address: '93.184.216.34', family: 4}, + ]); + const single = await new Promise((resolve) => + lookup('dual.example', {}, (error, address, family) => resolve({address, error, family})), + ); + assert.equal(single.address, '2606:4700:4700::1111'); + assert.equal(single.family, 6); + }); + + test('a registered origin whose IPv6 address refuses connects over IPv4 without waiting', async () => { + const {port, requested, server} = await startServer({ + '/ping': (_request, response) => sendImage(response, 'text/plain', 'ok'), + }); + try { + const outboundHTTP = new DesktopOutboundHTTP({ + resolveHostAddresses: stubResolver({'dual.example': ['::1', '127.0.0.1']}), + }); + await outboundHTTP.registerAnchoredOrigins({anchorOrigin: `http://dual.example:${port}`, origins: []}); + const startedAt = Date.now(); + const response = await outboundHTTP.request({ + body: null, + expectedOrigin: `http://dual.example:${port}`, + headers: null, + method: 'GET', + originTrust: DesktopOriginTrust.REGISTERED, + serviceName: 'test', + signal: null, + timeoutMs: 2000, + url: `http://dual.example:${port}/ping`, + }); + response.message.resume(); + assert.equal(response.status, 200, 'the refused IPv6 address must fall back to the IPv4 one like Chromium does'); + assert.ok(Date.now() - startedAt < 1000); + assert.deepEqual(requested, ['/ping']); + outboundHTTP.cleanup(); + } finally { + server.close(); + } + }); + + test('a registered origin whose first address never answers is reached on the next one', async () => { + const {port, requested, server} = await startServer({ + '/ping': (_request, response) => sendImage(response, 'text/plain', 'ok'), + }); + try { + const outboundHTTP = new DesktopOutboundHTTP({ + resolveHostAddresses: stubResolver({'multi.example': ['127.0.0.2', '127.0.0.1']}), + }); + await outboundHTTP.registerAnchoredOrigins({anchorOrigin: `http://multi.example:${port}`, origins: []}); + const startedAt = Date.now(); + const response = await outboundHTTP.request({ + body: null, + expectedOrigin: `http://multi.example:${port}`, + headers: null, + method: 'GET', + originTrust: DesktopOriginTrust.REGISTERED, + serviceName: 'test', + signal: null, + timeoutMs: 3000, + url: `http://multi.example:${port}/ping`, + }); + response.message.resume(); + assert.equal(response.status, 200, 'a dead first address must not stop the connection from trying the others'); + assert.ok(Date.now() - startedAt < 1500); + assert.deepEqual(requested, ['/ping']); + outboundHTTP.cleanup(); + } finally { + server.close(); + } + }); + + test('a public requirement still refuses an answer that includes a private address', async () => { + const outboundHTTP = new DesktopOutboundHTTP({ + resolveHostAddresses: stubResolver({'mixed.example': ['2606:4700:4700::1111', '127.0.0.1']}), + }); + const client = new DesktopSelectedInstanceClient(outboundHTTP); + await assert.rejects( + client.fetch({expectedOrigin: 'https://mixed.example', timeoutMs: 2000, url: 'https://mixed.example/ping'}), + ); + outboundHTTP.cleanup(); + }); + test('the pinned lookup refuses a hostname it was not created for', async () => { const lookup = createPinnedHostLookup('pinned.example', {address: '127.0.0.1', family: 4}); const mismatch = await new Promise((resolve) => lookup('rebound.example', {}, resolve)); diff --git a/fluxer_desktop/src/main/DesktopOutboundHTTP.ts b/fluxer_desktop/src/main/DesktopOutboundHTTP.ts index 93da88263..40380b6ed 100644 --- a/fluxer_desktop/src/main/DesktopOutboundHTTP.ts +++ b/fluxer_desktop/src/main/DesktopOutboundHTTP.ts @@ -87,6 +87,11 @@ export const DesktopOriginTrust = Object.freeze({ export type DesktopOriginTrust = (typeof DesktopOriginTrust)[keyof typeof DesktopOriginTrust]; +const HAPPY_EYEBALLS_CONNECT_OPTIONS = Object.freeze({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 250, +}); + interface PinnedAddress { readonly address: string; readonly family: 4 | 6; @@ -96,6 +101,7 @@ type DesktopHostAddressResolver = (hostname: string) => Promise; readonly origin: string; readonly scope: DesktopOriginAddressScope; readonly unreachable: boolean; @@ -515,8 +521,13 @@ function absoluteLookupHostname(hostname: string): string { return hostname; } -export function createPinnedHostLookup(hostname: string, pinned: PinnedAddress): LookupFunction { +export function createPinnedHostLookup( + hostname: string, + pinned: PinnedAddress | ReadonlyArray, +): LookupFunction { const expected = normalizeLookupHostname(hostname); + const addresses: ReadonlyArray = Array.isArray(pinned) ? pinned : [pinned as PinnedAddress]; + const primary = addresses[0]; return (requestedHostname, options, callback) => { process.nextTick(() => { if (normalizeLookupHostname(requestedHostname) !== expected) { @@ -524,10 +535,10 @@ export function createPinnedHostLookup(hostname: string, pinned: PinnedAddress): return; } if (options.all === true) { - (callback as unknown as (error: null, addresses: ReadonlyArray) => void)(null, [pinned]); + (callback as unknown as (error: null, addresses: ReadonlyArray) => void)(null, addresses); return; } - callback(null, pinned.address, pinned.family); + callback(null, primary.address, primary.family); }); }; } @@ -594,12 +605,17 @@ async function lookupAllAddresses(hostname: string): Promise record.address); } -function selectPinnedAddress(candidates: ReadonlyArray): PinnedAddress | null { - const nonPublic = candidates.filter((candidate) => !isPublicPinnedAddress(candidate)); +function selectPinnedAddresses(candidates: ReadonlyArray): ReadonlyArray { + const unique = candidates.filter( + (candidate, index) => + candidates.findIndex((other) => other.address === candidate.address && other.family === candidate.family) === + index, + ); + const nonPublic = unique.filter((candidate) => !isPublicPinnedAddress(candidate)); if (nonPublic.length === 0) { - return candidates[0] ?? null; + return unique; } - return nonPublic.find((candidate) => candidate.family === 4) ?? nonPublic[0]; + return nonPublic; } function addressScope(address: PinnedAddress): DesktopOriginAddressScope { @@ -892,7 +908,7 @@ export class DesktopOutboundHTTP { if (binding.scope === DesktopOriginAddressScope.PUBLIC) { throw new DesktopOutboundHTTPInsecureTransportError(canonical); } - return binding.address.address; + return (binding.addresses.find((address) => address.family === 4) ?? binding.address).address; } public cleanup(): void { @@ -1014,13 +1030,13 @@ export class DesktopOutboundHTTP { hostname: string, requirement: DesktopAddressRequirement, context: string, - ): Promise { + ): Promise> { const literal = parseIPAddress(hostname); if (literal != null) { if (requirement === DesktopAddressRequirement.PUBLIC && !isPublicPinnedAddress(literal)) { throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, context, hostname); } - return literal; + return [literal]; } let addresses: ReadonlyArray; try { @@ -1033,11 +1049,11 @@ export class DesktopOutboundHTTP { throw blocked(reason, context, hostname); } const candidates = addresses.map((address) => parseIPAddress(address)).filter((value) => value != null); - const pinned = selectPinnedAddress(candidates); - if (pinned == null) { + const pinned = selectPinnedAddresses(candidates); + if (pinned.length === 0) { throw blocked(DesktopOutboundBlockReason.NO_USABLE_ADDRESS, context, hostname); } - if (requirement === DesktopAddressRequirement.PUBLIC && !isPublicPinnedAddress(pinned)) { + if (requirement === DesktopAddressRequirement.PUBLIC && pinned.some((address) => !isPublicPinnedAddress(address))) { throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, context, hostname); } return pinned; @@ -1058,7 +1074,7 @@ export class DesktopOutboundHTTP { private issue( request: DesktopOutboundGETRequest, - pinned: PinnedAddress, + pinned: ReadonlyArray, release: () => void, ): Promise { return new Promise((resolve, reject) => { @@ -1067,6 +1083,7 @@ export class DesktopOutboundHTTP { const clientRequest = transport.request(request.url, { agent: secure ? this.httpsAgent : this.httpAgent, lookup: createPinnedHostLookup(request.url.hostname, pinned), + ...HAPPY_EYEBALLS_CONNECT_OPTIONS, method: 'GET', }); const deadline = setTimeout(() => { @@ -1167,15 +1184,16 @@ export class DesktopOutboundHTTP { const hostname = new URL(origin).hostname; const literal = parseIPAddress(hostname); if (literal != null) { - return {address: literal, origin, scope: addressScope(literal), unreachable: false}; + return {address: literal, addresses: [literal], origin, scope: addressScope(literal), unreachable: false}; } const rawAddresses = await this.resolveOriginHost(absoluteLookupHostname(hostname), origin); const candidates = rawAddresses.map((address) => parseIPAddress(address)).filter((value) => value != null); - const pinned = selectPinnedAddress(candidates); - if (pinned == null) { + const addresses = selectPinnedAddresses(candidates); + const primary = addresses[0]; + if (primary == null) { throw new DesktopOutboundHTTPEmptyResolutionError(origin); } - return {address: pinned, origin, scope: addressScope(pinned), unreachable: false}; + return {address: primary, addresses, origin, scope: addressScope(primary), unreachable: false}; } private markBindingUnreachable(binding: DesktopOriginAddressBinding): void { @@ -1274,7 +1292,8 @@ export class DesktopOutboundHTTP { const clientRequest = transport.request(target, { agent: target.protocol === 'https:' ? this.originRequestHttpsAgent : this.originRequestHttpAgent, headers: requestHeaders(request.headers), - lookup: createPinnedHostLookup(target.hostname, binding.address), + lookup: createPinnedHostLookup(target.hostname, binding.addresses), + ...HAPPY_EYEBALLS_CONNECT_OPTIONS, method: request.method, }); active = clientRequest;