Use electron's DNS resolver, prioritizing ipv4 connections
This commit is contained in:
parent
615539ed6d
commit
557b86f52e
7 changed files with 169 additions and 240 deletions
257
ts/util/dns.ts
257
ts/util/dns.ts
|
@ -1,15 +1,14 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { lookup as nativeLookup } from 'dns';
|
||||
import type { LookupOneOptions } from 'dns';
|
||||
import fetch from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
import type { LookupOneOptions, LookupAllOptions, LookupAddress } from 'dns';
|
||||
import { lookup as nodeLookup } from 'dns';
|
||||
import { ipcRenderer, net } from 'electron';
|
||||
import type { ResolvedHost } from 'electron';
|
||||
import { shuffle } from 'lodash';
|
||||
|
||||
import * as log from '../logging/log';
|
||||
import * as Errors from '../types/errors';
|
||||
import { strictAssert } from './assert';
|
||||
import { SECOND } from './durations';
|
||||
import { drop } from './drop';
|
||||
|
||||
const HOST_ALLOWLIST = new Set([
|
||||
// Production
|
||||
|
@ -33,171 +32,105 @@ const HOST_ALLOWLIST = new Set([
|
|||
'sfu.voip.signal.org',
|
||||
]);
|
||||
|
||||
const dohResponseSchema = z.object({
|
||||
Status: z.number(),
|
||||
Answer: z.array(
|
||||
z.object({
|
||||
data: z.string(),
|
||||
TTL: z.number(),
|
||||
})
|
||||
),
|
||||
Comment: z.string().optional(),
|
||||
});
|
||||
|
||||
type CacheEntry = Readonly<{
|
||||
data: string;
|
||||
expiresAt: number;
|
||||
}>;
|
||||
|
||||
export class DNSCache {
|
||||
private readonly ipv4 = new Map<string, Array<CacheEntry>>();
|
||||
private readonly ipv6 = new Map<string, Array<CacheEntry>>();
|
||||
|
||||
public get(hostname: string, family: 4 | 6): string | undefined {
|
||||
const map = this.getMap(family);
|
||||
|
||||
const entries = map.get(hostname);
|
||||
if (!entries) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Cleanup old records
|
||||
this.cleanup(entries);
|
||||
if (entries.length === 0) {
|
||||
map.delete(hostname);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Pick a random record
|
||||
return this.pick(entries);
|
||||
}
|
||||
|
||||
public setAndPick(
|
||||
hostname: string,
|
||||
family: 4 | 6,
|
||||
entries: Array<CacheEntry>
|
||||
): string {
|
||||
strictAssert(entries.length !== 0, 'should have at least on entry');
|
||||
|
||||
const map = this.getMap(family);
|
||||
|
||||
// Just overwrite the entries - we shouldn't get here unless it was a cache
|
||||
// miss.
|
||||
map.set(hostname, entries);
|
||||
|
||||
return this.pick(entries);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
private getMap(family: 4 | 6): Map<string, Array<CacheEntry>> {
|
||||
return family === 4 ? this.ipv4 : this.ipv6;
|
||||
}
|
||||
|
||||
private pick(entries: Array<CacheEntry>): string {
|
||||
const index = Math.floor(Math.random() * entries.length);
|
||||
return entries[index].data;
|
||||
}
|
||||
|
||||
private cleanup(entries: Array<CacheEntry>): void {
|
||||
const now = Date.now();
|
||||
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
||||
const { expiresAt } = entries[i];
|
||||
if (expiresAt <= now) {
|
||||
entries.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cache = new DNSCache();
|
||||
|
||||
export async function doh(hostname: string, family: 4 | 6): Promise<string> {
|
||||
const cached = cache.get(hostname, family);
|
||||
if (cached !== undefined) {
|
||||
log.info(`dns/doh: using cached value for ${hostname}/IPv${family}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const url = new URL('https://1.1.1.1/dns-query');
|
||||
url.searchParams.append('name', hostname);
|
||||
url.searchParams.append('type', family === 4 ? 'A' : 'AAAA');
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: {
|
||||
accept: 'application/dns-json',
|
||||
'user-agent': 'Electron',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`DoH request for ${hostname} failed with http status: ${res.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
Status: status,
|
||||
Answer: answer,
|
||||
Comment: comment,
|
||||
} = dohResponseSchema.parse(await res.json());
|
||||
|
||||
if (status !== 0) {
|
||||
throw new Error(`DoH request for ${hostname} failed: ${status}/${comment}`);
|
||||
}
|
||||
|
||||
if (answer.length === 0) {
|
||||
throw new Error(`DoH request for ${hostname} failed: empty answer`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
return cache.setAndPick(
|
||||
hostname,
|
||||
family,
|
||||
answer.map(({ data, TTL }) => {
|
||||
return { data, expiresAt: now + TTL * SECOND };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function lookupWithFallback(
|
||||
function lookupAll(
|
||||
hostname: string,
|
||||
opts: LookupOneOptions,
|
||||
opts: LookupOneOptions | LookupAllOptions,
|
||||
callback: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
address: string,
|
||||
family: number
|
||||
addresses: string | Array<LookupAddress>,
|
||||
family?: number
|
||||
) => void
|
||||
): void {
|
||||
if (!HOST_ALLOWLIST.has(hostname)) {
|
||||
nodeLookup(hostname, opts, callback);
|
||||
return;
|
||||
}
|
||||
|
||||
// Node.js support various signatures, but we only support one.
|
||||
strictAssert(typeof opts === 'object', 'missing options');
|
||||
strictAssert(Boolean(opts.all) !== true, 'options.all is not supported');
|
||||
strictAssert(typeof callback === 'function', 'missing callback');
|
||||
|
||||
nativeLookup(hostname, opts, async (err, ...nativeArgs) => {
|
||||
if (!err) {
|
||||
return callback(err, ...nativeArgs);
|
||||
}
|
||||
|
||||
if (!HOST_ALLOWLIST.has(hostname)) {
|
||||
log.error(
|
||||
`dns/lookup: failed for ${hostname}, ` +
|
||||
`err: ${Errors.toLogFormat(err)}. not retrying`
|
||||
);
|
||||
return callback(err, ...nativeArgs);
|
||||
}
|
||||
|
||||
const family = opts.family === 6 ? 6 : 4;
|
||||
|
||||
log.error(
|
||||
`dns/lookup: failed for ${hostname}, err: ${Errors.toLogFormat(err)}. ` +
|
||||
`Retrying with DoH (IPv${family})`
|
||||
);
|
||||
async function run() {
|
||||
let result: ResolvedHost;
|
||||
|
||||
try {
|
||||
const answer = await doh(hostname, family);
|
||||
callback(null, answer, family);
|
||||
} catch (fallbackErr) {
|
||||
callback(fallbackErr, '', 0);
|
||||
let queryType: 'A' | 'AAAA' | undefined;
|
||||
if (opts.family === 4) {
|
||||
queryType = 'A';
|
||||
} else if (opts.family === 6) {
|
||||
queryType = 'AAAA';
|
||||
}
|
||||
|
||||
if (net) {
|
||||
// Main process
|
||||
result = await net.resolveHost(hostname, {
|
||||
queryType,
|
||||
});
|
||||
} else {
|
||||
// Renderer
|
||||
result = await ipcRenderer.invoke(
|
||||
'net.resolveHost',
|
||||
hostname,
|
||||
queryType
|
||||
);
|
||||
}
|
||||
const addresses = result.endpoints.map(({ address, family }) => {
|
||||
let numericFamily = -1;
|
||||
if (family === 'ipv4') {
|
||||
numericFamily = 4;
|
||||
} else if (family === 'ipv6') {
|
||||
numericFamily = 6;
|
||||
}
|
||||
return {
|
||||
address,
|
||||
family: numericFamily,
|
||||
};
|
||||
});
|
||||
|
||||
const v4 = shuffle(addresses.filter(({ family }) => family === 4));
|
||||
const v6 = shuffle(addresses.filter(({ family }) => family === 6));
|
||||
|
||||
// Node.js should interleave v4 and v6 addresses when trying them with
|
||||
// Happy Eyeballs, but it does not do it yet.
|
||||
//
|
||||
// See: https://github.com/nodejs/node/pull/48258
|
||||
const interleaved = new Array<LookupAddress>();
|
||||
while (v4.length !== 0 || v6.length !== 0) {
|
||||
const v4Entry = v4.pop();
|
||||
// Prioritize v4 over v6
|
||||
if (v4Entry !== undefined) {
|
||||
interleaved.push(v4Entry);
|
||||
}
|
||||
const v6Entry = v6.pop();
|
||||
if (v6Entry !== undefined) {
|
||||
interleaved.push(v6Entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.all) {
|
||||
const random = interleaved.at(
|
||||
Math.floor(Math.random() * interleaved.length)
|
||||
);
|
||||
if (random === undefined) {
|
||||
callback(
|
||||
new Error(`Hostname: ${hostname} cannot be resolved`),
|
||||
'',
|
||||
-1
|
||||
);
|
||||
return;
|
||||
}
|
||||
callback(null, random.address, random.family);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(null, interleaved);
|
||||
} catch (error) {
|
||||
callback(error, []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
drop(run());
|
||||
}
|
||||
|
||||
// Note: `nodeLookup` has a complicated type due to compatibility requirements.
|
||||
export const electronLookup = lookupAll as typeof nodeLookup;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue