Support Happy Eyeballs in proxy-agent
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// Copyright 2013 Signal Messenger, LLC
|
||||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { Agent as HTTPSAgent } from 'https';
|
||||
@@ -11,7 +11,10 @@ import { callbackify, promisify } from 'util';
|
||||
import pTimeout from 'p-timeout';
|
||||
|
||||
import * as log from '../logging/log';
|
||||
import { electronLookup as electronLookupWithCb } from './dns';
|
||||
import {
|
||||
electronLookup as electronLookupWithCb,
|
||||
interleaveAddresses,
|
||||
} from './dns';
|
||||
import { strictAssert } from './assert';
|
||||
import { parseIntOrThrow } from './parseIntOrThrow';
|
||||
import { sleep } from './sleep';
|
||||
@@ -50,44 +53,11 @@ export class Agent extends HTTPSAgent {
|
||||
);
|
||||
|
||||
const addresses = await electronLookup(host, { all: true });
|
||||
const firstAddr = addresses.find(
|
||||
({ family }) => family === 4 || family === 6
|
||||
);
|
||||
if (!firstAddr) {
|
||||
throw new Error(`Agent.createConnection: failed to resolve ${host}`);
|
||||
}
|
||||
|
||||
const v4 = addresses.filter(({ family }) => family === 4);
|
||||
const v6 = addresses.filter(({ family }) => family === 6);
|
||||
|
||||
// Interleave addresses for Happy Eyeballs, but keep the first address
|
||||
// type from the DNS response first in the list.
|
||||
const interleaved = new Array<LookupAddress>();
|
||||
while (v4.length !== 0 || v6.length !== 0) {
|
||||
const v4Entry = v4.pop();
|
||||
const v6Entry = v6.pop();
|
||||
|
||||
if (firstAddr.family === 4) {
|
||||
if (v4Entry !== undefined) {
|
||||
interleaved.push(v4Entry);
|
||||
}
|
||||
if (v6Entry !== undefined) {
|
||||
interleaved.push(v6Entry);
|
||||
}
|
||||
} else {
|
||||
if (v6Entry !== undefined) {
|
||||
interleaved.push(v6Entry);
|
||||
}
|
||||
if (v4Entry !== undefined) {
|
||||
interleaved.push(v4Entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const { socket, address, v4Attempts, v6Attempts } = await happyEyeballs({
|
||||
addrs: interleaved,
|
||||
addresses,
|
||||
port,
|
||||
tlsOptions: {
|
||||
ca: options.ca,
|
||||
@@ -113,9 +83,10 @@ export class Agent extends HTTPSAgent {
|
||||
}
|
||||
|
||||
export type HappyEyeballsOptions = Readonly<{
|
||||
addrs: ReadonlyArray<LookupAddress>;
|
||||
addresses: ReadonlyArray<LookupAddress>;
|
||||
port?: number;
|
||||
tlsOptions: ConnectionOptions;
|
||||
connect?: typeof defaultConnect;
|
||||
tlsOptions?: ConnectionOptions;
|
||||
}>;
|
||||
|
||||
export type HappyEyeballsResult = Readonly<{
|
||||
@@ -126,17 +97,19 @@ export type HappyEyeballsResult = Readonly<{
|
||||
}>;
|
||||
|
||||
export async function happyEyeballs({
|
||||
addrs,
|
||||
addresses,
|
||||
port = 443,
|
||||
tlsOptions,
|
||||
connect = defaultConnect,
|
||||
}: HappyEyeballsOptions): Promise<HappyEyeballsResult> {
|
||||
const abortControllers = addrs.map(() => new AbortController());
|
||||
|
||||
let v4Attempts = 0;
|
||||
let v6Attempts = 0;
|
||||
|
||||
const interleaved = interleaveAddresses(addresses);
|
||||
const abortControllers = interleaved.map(() => new AbortController());
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
addrs.map(async (addr, index) => {
|
||||
interleaved.map(async (addr, index) => {
|
||||
const abortController = abortControllers[index];
|
||||
if (index !== 0) {
|
||||
await sleep(index * DELAY_MS, abortController.signal);
|
||||
@@ -148,12 +121,16 @@ export async function happyEyeballs({
|
||||
v6Attempts += 1;
|
||||
}
|
||||
|
||||
const socket = await connect({
|
||||
address: addr.address,
|
||||
port,
|
||||
tlsOptions,
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
const socket = await pTimeout(
|
||||
connect({
|
||||
address: addr.address,
|
||||
port,
|
||||
tlsOptions,
|
||||
abortSignal: abortController.signal,
|
||||
}),
|
||||
CONNECT_TIMEOUT_MS,
|
||||
'createHTTPSAgent.connect: connection timed out'
|
||||
);
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Aborted');
|
||||
@@ -179,7 +156,7 @@ export async function happyEyeballs({
|
||||
|
||||
return {
|
||||
socket,
|
||||
address: addrs[index],
|
||||
address: interleaved[index],
|
||||
v4Attempts,
|
||||
v6Attempts,
|
||||
};
|
||||
@@ -197,45 +174,37 @@ export async function happyEyeballs({
|
||||
throw results[0].reason;
|
||||
}
|
||||
|
||||
type DelayedConnectOptionsType = Readonly<{
|
||||
export type ConnectOptionsType = Readonly<{
|
||||
port: number;
|
||||
address: string;
|
||||
tlsOptions: ConnectionOptions;
|
||||
tlsOptions?: ConnectionOptions;
|
||||
abortSignal?: AbortSignal;
|
||||
timeout?: number;
|
||||
}>;
|
||||
|
||||
async function connect({
|
||||
async function defaultConnect({
|
||||
port,
|
||||
address,
|
||||
tlsOptions,
|
||||
abortSignal,
|
||||
timeout = CONNECT_TIMEOUT_MS,
|
||||
}: DelayedConnectOptionsType): Promise<net.Socket> {
|
||||
}: ConnectOptionsType): Promise<net.Socket> {
|
||||
const socket = tls.connect(port, address, {
|
||||
...tlsOptions,
|
||||
signal: abortSignal,
|
||||
});
|
||||
|
||||
return pTimeout(
|
||||
(async () => {
|
||||
const { promise: onHandshake, resolve, reject } = explodePromise<void>();
|
||||
const { promise: onHandshake, resolve, reject } = explodePromise<void>();
|
||||
|
||||
socket.once('secureConnect', resolve);
|
||||
socket.once('error', reject);
|
||||
socket.once('secureConnect', resolve);
|
||||
socket.once('error', reject);
|
||||
|
||||
try {
|
||||
await onHandshake;
|
||||
} finally {
|
||||
socket.removeListener('secureConnect', resolve);
|
||||
socket.removeListener('error', reject);
|
||||
}
|
||||
try {
|
||||
await onHandshake;
|
||||
} finally {
|
||||
socket.removeListener('secureConnect', resolve);
|
||||
socket.removeListener('error', reject);
|
||||
}
|
||||
|
||||
return socket;
|
||||
})(),
|
||||
timeout,
|
||||
'createHTTPSAgent.connect: connection timed out'
|
||||
);
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function createHTTPSAgent(options: AgentOptions = {}): Agent {
|
||||
|
134
ts/util/createProxyAgent.ts
Normal file
134
ts/util/createProxyAgent.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { ProxyAgent } from 'proxy-agent';
|
||||
import net from 'net';
|
||||
import { URL } from 'url';
|
||||
import type { LookupOneOptions, LookupAddress } from 'dns';
|
||||
import { lookup } from 'dns/promises';
|
||||
|
||||
import * as log from '../logging/log';
|
||||
import { happyEyeballs } from './createHTTPSAgent';
|
||||
import type { ConnectOptionsType } from './createHTTPSAgent';
|
||||
import { explodePromise } from './explodePromise';
|
||||
import { SECOND } from './durations';
|
||||
import { drop } from './drop';
|
||||
|
||||
// Warning threshold
|
||||
const CONNECT_THRESHOLD_MS = SECOND;
|
||||
|
||||
const SOCKS_PROTOCOLS = new Set([
|
||||
'socks:',
|
||||
'socks4:',
|
||||
'socks4a:',
|
||||
'socks5:',
|
||||
'socks5h:',
|
||||
]);
|
||||
|
||||
export function createProxyAgent(proxyUrl: string): ProxyAgent {
|
||||
const { port: portStr, hostname: proxyHost, protocol } = new URL(proxyUrl);
|
||||
let defaultPort: number | undefined;
|
||||
if (protocol === 'http:') {
|
||||
defaultPort = 80;
|
||||
} else if (protocol === 'https:') {
|
||||
defaultPort = 443;
|
||||
} else if (SOCKS_PROTOCOLS.has(protocol)) {
|
||||
defaultPort = 1080;
|
||||
}
|
||||
const port = portStr ? parseInt(portStr, 10) : defaultPort;
|
||||
|
||||
async function happyLookup(
|
||||
host: string,
|
||||
opts: LookupOneOptions
|
||||
): Promise<LookupAddress> {
|
||||
if (opts.all) {
|
||||
throw new Error('createProxyAgent: all=true lookup is not supported');
|
||||
}
|
||||
|
||||
const addresses = await lookup(host, { all: true });
|
||||
|
||||
// SOCKS 4/5 resolve target host before sending it to the proxy.
|
||||
if (host !== proxyHost) {
|
||||
const idx = Math.floor(Math.random() * addresses.length);
|
||||
return addresses[idx];
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const { socket, address, v4Attempts, v6Attempts } = await happyEyeballs({
|
||||
addresses,
|
||||
port,
|
||||
connect,
|
||||
});
|
||||
|
||||
const duration = Date.now() - start;
|
||||
const logLine =
|
||||
`createProxyAgent.lookup(${host}): connected to ` +
|
||||
`IPv${address.family} addr after ${duration}ms ` +
|
||||
`(attempts v4=${v4Attempts} v6=${v6Attempts})`;
|
||||
|
||||
if (v4Attempts + v6Attempts > 1 || duration > CONNECT_THRESHOLD_MS) {
|
||||
log.warn(logLine);
|
||||
} else {
|
||||
log.info(logLine);
|
||||
}
|
||||
|
||||
// Sadly we can't return socket to proxy-agent
|
||||
socket.destroy();
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
async function happyLookupWithCallback(
|
||||
host: string,
|
||||
opts: LookupOneOptions,
|
||||
callback: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
address: string,
|
||||
family: number
|
||||
) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { address, family } = await happyLookup(host, opts);
|
||||
callback(null, address, family);
|
||||
} catch (error) {
|
||||
callback(error, '', -1);
|
||||
}
|
||||
}
|
||||
|
||||
return new ProxyAgent({
|
||||
lookup:
|
||||
port !== undefined
|
||||
? (...args) => drop(happyLookupWithCallback(...args))
|
||||
: undefined,
|
||||
getProxyForUrl() {
|
||||
return proxyUrl;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function connect({
|
||||
port,
|
||||
address,
|
||||
abortSignal,
|
||||
}: ConnectOptionsType): Promise<net.Socket> {
|
||||
const socket = net.connect({
|
||||
port,
|
||||
host: address,
|
||||
signal: abortSignal,
|
||||
});
|
||||
|
||||
const { promise: onConnect, resolve, reject } = explodePromise<void>();
|
||||
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
|
||||
try {
|
||||
await onConnect;
|
||||
} finally {
|
||||
socket.removeListener('connect', resolve);
|
||||
socket.removeListener('error', reject);
|
||||
}
|
||||
|
||||
return socket;
|
||||
}
|
@@ -111,5 +111,45 @@ function lookupAll(
|
||||
drop(run());
|
||||
}
|
||||
|
||||
export function interleaveAddresses(
|
||||
addresses: ReadonlyArray<LookupAddress>
|
||||
): Array<LookupAddress> {
|
||||
const firstAddr = addresses.find(
|
||||
({ family }) => family === 4 || family === 6
|
||||
);
|
||||
if (!firstAddr) {
|
||||
throw new Error('interleaveAddresses: no addresses to interleave');
|
||||
}
|
||||
|
||||
const v4 = addresses.filter(({ family }) => family === 4);
|
||||
const v6 = addresses.filter(({ family }) => family === 6);
|
||||
|
||||
// Interleave addresses for Happy Eyeballs, but keep the first address
|
||||
// type from the DNS response first in the list.
|
||||
const interleaved = new Array<LookupAddress>();
|
||||
while (v4.length !== 0 || v6.length !== 0) {
|
||||
const v4Entry = v4.pop();
|
||||
const v6Entry = v6.pop();
|
||||
|
||||
if (firstAddr.family === 4) {
|
||||
if (v4Entry !== undefined) {
|
||||
interleaved.push(v4Entry);
|
||||
}
|
||||
if (v6Entry !== undefined) {
|
||||
interleaved.push(v6Entry);
|
||||
}
|
||||
} else {
|
||||
if (v6Entry !== undefined) {
|
||||
interleaved.push(v6Entry);
|
||||
}
|
||||
if (v4Entry !== undefined) {
|
||||
interleaved.push(v4Entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return interleaved;
|
||||
}
|
||||
|
||||
// Note: `nodeLookup` has a complicated type due to compatibility requirements.
|
||||
export const electronLookup = lookupAll as typeof nodeLookup;
|
||||
|
@@ -621,6 +621,13 @@
|
||||
"reasonCategory": "testCode",
|
||||
"updated": "2022-06-23T23:21:04.555Z"
|
||||
},
|
||||
{
|
||||
"rule": "eval",
|
||||
"path": "node_modules/@tootallnate/quickjs-emscripten/dist/context.js",
|
||||
"line": " * Like [`eval(code)`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval#Description).",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2023-08-29T19:25:52.732Z"
|
||||
},
|
||||
{
|
||||
"rule": "eval",
|
||||
"path": "node_modules/agentkeepalive/node_modules/depd/index.js",
|
||||
@@ -696,13 +703,6 @@
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2023-04-20T16:43:40.643Z"
|
||||
},
|
||||
{
|
||||
"rule": "thenify-multiArgs",
|
||||
"path": "node_modules/make-dir/node_modules/pify/index.js",
|
||||
"line": "\t\tif (options.multiArgs) {",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2023-08-21T18:28:53.361Z"
|
||||
},
|
||||
{
|
||||
"rule": "DOM-outerHTML",
|
||||
"path": "node_modules/domutils/node_modules/dom-serializer/lib/esm/index.js",
|
||||
@@ -1133,6 +1133,13 @@
|
||||
"reasonCategory": "notExercisedByOurApp",
|
||||
"updated": "2023-06-29T17:01:25.145Z"
|
||||
},
|
||||
{
|
||||
"rule": "thenify-multiArgs",
|
||||
"path": "node_modules/make-dir/node_modules/pify/index.js",
|
||||
"line": "\t\tif (options.multiArgs) {",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2023-08-21T18:28:53.361Z"
|
||||
},
|
||||
{
|
||||
"rule": "DOM-innerHTML",
|
||||
"path": "node_modules/min-document/serialize.js",
|
||||
@@ -1199,13 +1206,6 @@
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-06-04T00:50:49.405Z"
|
||||
},
|
||||
{
|
||||
"rule": "eval",
|
||||
"path": "node_modules/pac-proxy-agent/node_modules/depd/index.js",
|
||||
"line": " var deprecatedfn = eval('(function (' + args + ') {\\n' +",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2022-12-13T00:55:48.389Z"
|
||||
},
|
||||
{
|
||||
"rule": "DOM-innerHTML",
|
||||
"path": "node_modules/parse-entities/decode-entity.browser.js",
|
||||
@@ -1935,22 +1935,6 @@
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2022-06-16T23:23:32.306Z"
|
||||
},
|
||||
{
|
||||
"rule": "eval",
|
||||
"path": "node_modules/vm2/lib/nodevm.js",
|
||||
"line": "\t * @param {boolean} [options.eval=true] - Allow the dynamic evaluation of code via eval(code) or Function(code)().<br>",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-16T15:30:35.122Z",
|
||||
"reasonDetail": "falseMatch"
|
||||
},
|
||||
{
|
||||
"rule": "eval",
|
||||
"path": "node_modules/vm2/lib/vm.js",
|
||||
"line": "\t * @param {boolean} [options.eval=true] - Allow the dynamic evaluation of code via eval(code) or Function(code)().<br>",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-16T15:30:35.122Z",
|
||||
"reasonDetail": "This is a comment."
|
||||
},
|
||||
{
|
||||
"rule": "eval",
|
||||
"path": "node_modules/workerpool/dist/worker.js",
|
||||
|
Reference in New Issue
Block a user