mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(desktop): fall back across resolved addresses on connect (#3275)
This commit is contained in:
1 parent
7a4aa42d4b
commit
1515a8487f
2 files changed
+131
-19
No files matched your search
@@ -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 () => {
|
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 lookup = createPinnedHostLookup('pinned.example', {address: '127.0.0.1', family: 4});
|
||||||
const mismatch = await new Promise((resolve) => lookup('rebound.example', {}, resolve));
|
const mismatch = await new Promise((resolve) => lookup('rebound.example', {}, resolve));
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ export const DesktopOriginTrust = Object.freeze({
|
|||||||
|
|
||||||
export type DesktopOriginTrust = (typeof DesktopOriginTrust)[keyof typeof DesktopOriginTrust];
|
export type DesktopOriginTrust = (typeof DesktopOriginTrust)[keyof typeof DesktopOriginTrust];
|
||||||
|
|
||||||
|
const HAPPY_EYEBALLS_CONNECT_OPTIONS = Object.freeze({
|
||||||
|
autoSelectFamily: true,
|
||||||
|
autoSelectFamilyAttemptTimeout: 250,
|
||||||
|
});
|
||||||
|
|
||||||
interface PinnedAddress {
|
interface PinnedAddress {
|
||||||
readonly address: string;
|
readonly address: string;
|
||||||
readonly family: 4 | 6;
|
readonly family: 4 | 6;
|
||||||
@@ -96,6 +101,7 @@ type DesktopHostAddressResolver = (hostname: string) => Promise<ReadonlyArray<st
|
|||||||
|
|
||||||
interface DesktopOriginAddressBinding {
|
interface DesktopOriginAddressBinding {
|
||||||
readonly address: PinnedAddress;
|
readonly address: PinnedAddress;
|
||||||
|
readonly addresses: ReadonlyArray<PinnedAddress>;
|
||||||
readonly origin: string;
|
readonly origin: string;
|
||||||
readonly scope: DesktopOriginAddressScope;
|
readonly scope: DesktopOriginAddressScope;
|
||||||
readonly unreachable: boolean;
|
readonly unreachable: boolean;
|
||||||
@@ -515,8 +521,13 @@ function absoluteLookupHostname(hostname: string): string {
|
|||||||
return hostname;
|
return hostname;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPinnedHostLookup(hostname: string, pinned: PinnedAddress): LookupFunction {
|
export function createPinnedHostLookup(
|
||||||
|
hostname: string,
|
||||||
|
pinned: PinnedAddress | ReadonlyArray<PinnedAddress>,
|
||||||
|
): LookupFunction {
|
||||||
const expected = normalizeLookupHostname(hostname);
|
const expected = normalizeLookupHostname(hostname);
|
||||||
|
const addresses: ReadonlyArray<PinnedAddress> = Array.isArray(pinned) ? pinned : [pinned as PinnedAddress];
|
||||||
|
const primary = addresses[0];
|
||||||
return (requestedHostname, options, callback) => {
|
return (requestedHostname, options, callback) => {
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
if (normalizeLookupHostname(requestedHostname) !== expected) {
|
if (normalizeLookupHostname(requestedHostname) !== expected) {
|
||||||
@@ -524,10 +535,10 @@ export function createPinnedHostLookup(hostname: string, pinned: PinnedAddress):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.all === true) {
|
if (options.all === true) {
|
||||||
(callback as unknown as (error: null, addresses: ReadonlyArray<PinnedAddress>) => void)(null, [pinned]);
|
(callback as unknown as (error: null, addresses: ReadonlyArray<PinnedAddress>) => void)(null, addresses);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
callback(null, pinned.address, pinned.family);
|
callback(null, primary.address, primary.family);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -594,12 +605,17 @@ async function lookupAllAddresses(hostname: string): Promise<ReadonlyArray<strin
|
|||||||
return records.map((record) => record.address);
|
return records.map((record) => record.address);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPinnedAddress(candidates: ReadonlyArray<PinnedAddress>): PinnedAddress | null {
|
function selectPinnedAddresses(candidates: ReadonlyArray<PinnedAddress>): ReadonlyArray<PinnedAddress> {
|
||||||
const nonPublic = candidates.filter((candidate) => !isPublicPinnedAddress(candidate));
|
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) {
|
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 {
|
function addressScope(address: PinnedAddress): DesktopOriginAddressScope {
|
||||||
@@ -892,7 +908,7 @@ export class DesktopOutboundHTTP {
|
|||||||
if (binding.scope === DesktopOriginAddressScope.PUBLIC) {
|
if (binding.scope === DesktopOriginAddressScope.PUBLIC) {
|
||||||
throw new DesktopOutboundHTTPInsecureTransportError(canonical);
|
throw new DesktopOutboundHTTPInsecureTransportError(canonical);
|
||||||
}
|
}
|
||||||
return binding.address.address;
|
return (binding.addresses.find((address) => address.family === 4) ?? binding.address).address;
|
||||||
}
|
}
|
||||||
|
|
||||||
public cleanup(): void {
|
public cleanup(): void {
|
||||||
@@ -1014,13 +1030,13 @@ export class DesktopOutboundHTTP {
|
|||||||
hostname: string,
|
hostname: string,
|
||||||
requirement: DesktopAddressRequirement,
|
requirement: DesktopAddressRequirement,
|
||||||
context: string,
|
context: string,
|
||||||
): Promise<PinnedAddress> {
|
): Promise<ReadonlyArray<PinnedAddress>> {
|
||||||
const literal = parseIPAddress(hostname);
|
const literal = parseIPAddress(hostname);
|
||||||
if (literal != null) {
|
if (literal != null) {
|
||||||
if (requirement === DesktopAddressRequirement.PUBLIC && !isPublicPinnedAddress(literal)) {
|
if (requirement === DesktopAddressRequirement.PUBLIC && !isPublicPinnedAddress(literal)) {
|
||||||
throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, context, hostname);
|
throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, context, hostname);
|
||||||
}
|
}
|
||||||
return literal;
|
return [literal];
|
||||||
}
|
}
|
||||||
let addresses: ReadonlyArray<string>;
|
let addresses: ReadonlyArray<string>;
|
||||||
try {
|
try {
|
||||||
@@ -1033,11 +1049,11 @@ export class DesktopOutboundHTTP {
|
|||||||
throw blocked(reason, context, hostname);
|
throw blocked(reason, context, hostname);
|
||||||
}
|
}
|
||||||
const candidates = addresses.map((address) => parseIPAddress(address)).filter((value) => value != null);
|
const candidates = addresses.map((address) => parseIPAddress(address)).filter((value) => value != null);
|
||||||
const pinned = selectPinnedAddress(candidates);
|
const pinned = selectPinnedAddresses(candidates);
|
||||||
if (pinned == null) {
|
if (pinned.length === 0) {
|
||||||
throw blocked(DesktopOutboundBlockReason.NO_USABLE_ADDRESS, context, hostname);
|
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);
|
throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, context, hostname);
|
||||||
}
|
}
|
||||||
return pinned;
|
return pinned;
|
||||||
@@ -1058,7 +1074,7 @@ export class DesktopOutboundHTTP {
|
|||||||
|
|
||||||
private issue(
|
private issue(
|
||||||
request: DesktopOutboundGETRequest,
|
request: DesktopOutboundGETRequest,
|
||||||
pinned: PinnedAddress,
|
pinned: ReadonlyArray<PinnedAddress>,
|
||||||
release: () => void,
|
release: () => void,
|
||||||
): Promise<DesktopOutboundHTTPMessage> {
|
): Promise<DesktopOutboundHTTPMessage> {
|
||||||
return new Promise<DesktopOutboundHTTPMessage>((resolve, reject) => {
|
return new Promise<DesktopOutboundHTTPMessage>((resolve, reject) => {
|
||||||
@@ -1067,6 +1083,7 @@ export class DesktopOutboundHTTP {
|
|||||||
const clientRequest = transport.request(request.url, {
|
const clientRequest = transport.request(request.url, {
|
||||||
agent: secure ? this.httpsAgent : this.httpAgent,
|
agent: secure ? this.httpsAgent : this.httpAgent,
|
||||||
lookup: createPinnedHostLookup(request.url.hostname, pinned),
|
lookup: createPinnedHostLookup(request.url.hostname, pinned),
|
||||||
|
...HAPPY_EYEBALLS_CONNECT_OPTIONS,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
});
|
});
|
||||||
const deadline = setTimeout(() => {
|
const deadline = setTimeout(() => {
|
||||||
@@ -1167,15 +1184,16 @@ export class DesktopOutboundHTTP {
|
|||||||
const hostname = new URL(origin).hostname;
|
const hostname = new URL(origin).hostname;
|
||||||
const literal = parseIPAddress(hostname);
|
const literal = parseIPAddress(hostname);
|
||||||
if (literal != null) {
|
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 rawAddresses = await this.resolveOriginHost(absoluteLookupHostname(hostname), origin);
|
||||||
const candidates = rawAddresses.map((address) => parseIPAddress(address)).filter((value) => value != null);
|
const candidates = rawAddresses.map((address) => parseIPAddress(address)).filter((value) => value != null);
|
||||||
const pinned = selectPinnedAddress(candidates);
|
const addresses = selectPinnedAddresses(candidates);
|
||||||
if (pinned == null) {
|
const primary = addresses[0];
|
||||||
|
if (primary == null) {
|
||||||
throw new DesktopOutboundHTTPEmptyResolutionError(origin);
|
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 {
|
private markBindingUnreachable(binding: DesktopOriginAddressBinding): void {
|
||||||
@@ -1274,7 +1292,8 @@ export class DesktopOutboundHTTP {
|
|||||||
const clientRequest = transport.request(target, {
|
const clientRequest = transport.request(target, {
|
||||||
agent: target.protocol === 'https:' ? this.originRequestHttpsAgent : this.originRequestHttpAgent,
|
agent: target.protocol === 'https:' ? this.originRequestHttpsAgent : this.originRequestHttpAgent,
|
||||||
headers: requestHeaders(request.headers),
|
headers: requestHeaders(request.headers),
|
||||||
lookup: createPinnedHostLookup(target.hostname, binding.address),
|
lookup: createPinnedHostLookup(target.hostname, binding.addresses),
|
||||||
|
...HAPPY_EYEBALLS_CONNECT_OPTIONS,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
});
|
});
|
||||||
active = clientRequest;
|
active = clientRequest;
|
||||||
|
|||||||
Reference in new issue
Block a user