signal-desktop/ts/util/createHTTPSAgent.ts

237 lines
5.9 KiB
TypeScript
Raw Normal View History

2023-08-29 23:58:48 +00:00
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
2023-06-05 19:55:09 +00:00
import { Agent as HTTPSAgent } from 'https';
import type { AgentOptions, RequestOptions } from 'https';
import type { LookupAddress } from 'dns';
import type net from 'net';
2023-06-05 19:55:09 +00:00
import tls from 'tls';
import type { ConnectionOptions } from 'tls';
2023-06-05 19:55:09 +00:00
import { callbackify, promisify } from 'util';
import pTimeout from 'p-timeout';
2023-06-05 19:55:09 +00:00
import * as log from '../logging/log';
2023-08-29 23:58:48 +00:00
import {
electronLookup as electronLookupWithCb,
interleaveAddresses,
} from './dns';
2023-06-05 19:55:09 +00:00
import { strictAssert } from './assert';
import { parseIntOrThrow } from './parseIntOrThrow';
import { sleep } from './sleep';
import { SECOND } from './durations';
2023-06-05 19:55:09 +00:00
import { dropNull } from './dropNull';
import { explodePromise } from './explodePromise';
// https://www.rfc-editor.org/rfc/rfc8305#section-8 recommends 250ms, but since
// we also try to establish a TLS session - use higher value.
const DELAY_MS = 500;
2023-06-05 19:55:09 +00:00
// Warning threshold
const CONNECT_THRESHOLD_MS = SECOND;
const CONNECT_TIMEOUT_MS = 10 * SECOND;
const electronLookup = promisify(electronLookupWithCb);
2023-12-12 22:57:09 +00:00
const HOST_LOG_ALLOWLIST = new Set([
// Production
'chat.signal.org',
'storage.signal.org',
'cdsi.signal.org',
'cdn.signal.org',
'cdn2.signal.org',
'create.signal.art',
// Staging
'chat.staging.signal.org',
'storage-staging.signal.org',
'cdsi.staging.signal.org',
'cdn-staging.signal.org',
'cdn2-staging.signal.org',
'create.staging.signal.art',
// Common
'updates2.signal.org',
'sfu.voip.signal.org',
]);
2023-06-05 19:55:09 +00:00
export class Agent extends HTTPSAgent {
constructor(options: AgentOptions = {}) {
super({
...options,
lookup: electronLookup,
});
}
public createConnection = callbackify(
async (options: RequestOptions): Promise<net.Socket> => {
const { host = options.hostname, port: portString } = options;
strictAssert(host, 'Agent.createConnection: Missing options.host');
strictAssert(portString, 'Agent.createConnection: Missing options.port');
const port = parseIntOrThrow(
portString,
'Agent.createConnection: options.port is not an integer'
);
const addresses = await electronLookup(host, { all: true });
const start = Date.now();
const { socket, address, v4Attempts, v6Attempts } = await happyEyeballs({
2023-08-29 23:58:48 +00:00
addresses,
port,
tlsOptions: {
ca: options.ca,
2023-06-08 21:10:41 +00:00
servername: options.servername ?? dropNull(options.host),
},
});
2023-06-05 19:55:09 +00:00
2023-12-12 22:57:09 +00:00
if (HOST_LOG_ALLOWLIST.has(host)) {
const duration = Date.now() - start;
const logLine =
`createHTTPSAgent.createConnection(${host}): connected to ` +
`IPv${address.family} addr after ${duration}ms ` +
`(attempts v4=${v4Attempts} v6=${v6Attempts})`;
2023-06-05 19:55:09 +00:00
2023-12-12 22:57:09 +00:00
if (v4Attempts + v6Attempts > 1 || duration > CONNECT_THRESHOLD_MS) {
log.warn(logLine);
} else {
log.info(logLine);
}
2023-06-05 19:55:09 +00:00
}
return socket;
2023-06-05 19:55:09 +00:00
}
);
}
export type HappyEyeballsOptions = Readonly<{
2023-08-29 23:58:48 +00:00
addresses: ReadonlyArray<LookupAddress>;
port?: number;
2023-08-29 23:58:48 +00:00
connect?: typeof defaultConnect;
tlsOptions?: ConnectionOptions;
}>;
2023-06-05 19:55:09 +00:00
export type HappyEyeballsResult = Readonly<{
socket: net.Socket;
address: LookupAddress;
v4Attempts: number;
v6Attempts: number;
}>;
export async function happyEyeballs({
2023-08-29 23:58:48 +00:00
addresses,
port = 443,
tlsOptions,
2023-08-29 23:58:48 +00:00
connect = defaultConnect,
}: HappyEyeballsOptions): Promise<HappyEyeballsResult> {
2023-06-05 19:55:09 +00:00
let v4Attempts = 0;
let v6Attempts = 0;
2023-08-29 23:58:48 +00:00
const interleaved = interleaveAddresses(addresses);
const abortControllers = interleaved.map(() => new AbortController());
2023-06-05 19:55:09 +00:00
const results = await Promise.allSettled(
2023-08-29 23:58:48 +00:00
interleaved.map(async (addr, index) => {
2023-06-05 19:55:09 +00:00
const abortController = abortControllers[index];
if (index !== 0) {
await sleep(index * DELAY_MS, abortController.signal);
}
2023-06-05 19:55:09 +00:00
if (addr.family === 4) {
v4Attempts += 1;
} else {
v6Attempts += 1;
}
2023-08-29 23:58:48 +00:00
const socket = await pTimeout(
connect({
address: addr.address,
port,
tlsOptions,
abortSignal: abortController.signal,
}),
CONNECT_TIMEOUT_MS,
'createHTTPSAgent.connect: connection timed out'
);
2023-06-05 19:55:09 +00:00
if (abortController.signal.aborted) {
throw new Error('Aborted');
}
2023-06-05 19:55:09 +00:00
// Abort other connection attempts
for (const otherController of abortControllers) {
if (otherController !== abortController) {
otherController.abort();
}
}
return { socket, abortController, index };
})
);
const fulfilled = results.find(({ status }) => status === 'fulfilled');
if (fulfilled) {
strictAssert(
fulfilled.status === 'fulfilled',
'Fulfilled promise was not fulfilled'
);
const { socket, index } = fulfilled.value;
return {
socket,
2023-08-29 23:58:48 +00:00
address: interleaved[index],
2023-06-05 19:55:09 +00:00
v4Attempts,
v6Attempts,
};
2023-06-05 19:55:09 +00:00
}
strictAssert(
results[0].status === 'rejected',
'No fulfilled promises, but no rejected either'
);
// Abort all connection attempts for consistency
for (const controller of abortControllers) {
controller.abort();
}
throw results[0].reason;
}
2023-08-29 23:58:48 +00:00
export type ConnectOptionsType = Readonly<{
2023-06-05 19:55:09 +00:00
port: number;
address: string;
2023-08-29 23:58:48 +00:00
tlsOptions?: ConnectionOptions;
2023-06-05 19:55:09 +00:00
abortSignal?: AbortSignal;
}>;
2023-08-29 23:58:48 +00:00
async function defaultConnect({
2023-06-05 19:55:09 +00:00
port,
address,
tlsOptions,
2023-06-05 19:55:09 +00:00
abortSignal,
2023-08-29 23:58:48 +00:00
}: ConnectOptionsType): Promise<net.Socket> {
const socket = tls.connect(port, address, {
...tlsOptions,
2023-06-05 19:55:09 +00:00
signal: abortSignal,
});
2023-08-29 23:58:48 +00:00
const { promise: onHandshake, resolve, reject } = explodePromise<void>();
2023-08-29 23:58:48 +00:00
socket.once('secureConnect', resolve);
socket.once('error', reject);
2023-08-29 23:58:48 +00:00
try {
await onHandshake;
} finally {
socket.removeListener('secureConnect', resolve);
socket.removeListener('error', reject);
}
2023-06-05 19:55:09 +00:00
2023-08-29 23:58:48 +00:00
return socket;
2023-06-05 19:55:09 +00:00
}
export function createHTTPSAgent(options: AgentOptions = {}): Agent {
return new Agent(options);
}