|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { |
|
Resolver, |
|
ResolverListener, |
|
registerResolver, |
|
registerDefaultScheme, |
|
} from './resolver'; |
|
import * as dns from 'dns'; |
|
import * as util from 'util'; |
|
import { extractAndSelectServiceConfig, ServiceConfig } from './service-config'; |
|
import { Status } from './constants'; |
|
import { StatusObject } from './call-interface'; |
|
import { Metadata } from './metadata'; |
|
import * as logging from './logging'; |
|
import { LogVerbosity } from './constants'; |
|
import { SubchannelAddress, TcpSubchannelAddress } from './subchannel-address'; |
|
import { GrpcUri, uriToString, splitHostPort } from './uri-parser'; |
|
import { isIPv6, isIPv4 } from 'net'; |
|
import { ChannelOptions } from './channel-options'; |
|
import { BackoffOptions, BackoffTimeout } from './backoff-timeout'; |
|
|
|
const TRACER_NAME = 'dns_resolver'; |
|
|
|
function trace(text: string): void { |
|
logging.trace(LogVerbosity.DEBUG, TRACER_NAME, text); |
|
} |
|
|
|
|
|
|
|
|
|
export const DEFAULT_PORT = 443; |
|
|
|
const DEFAULT_MIN_TIME_BETWEEN_RESOLUTIONS_MS = 30_000; |
|
|
|
const resolveTxtPromise = util.promisify(dns.resolveTxt); |
|
const dnsLookupPromise = util.promisify(dns.lookup); |
|
|
|
|
|
|
|
|
|
|
|
function mergeArrays<T>(...arrays: T[][]): T[] { |
|
const result: T[] = []; |
|
for ( |
|
let i = 0; |
|
i < |
|
Math.max.apply( |
|
null, |
|
arrays.map(array => array.length) |
|
); |
|
i++ |
|
) { |
|
for (const array of arrays) { |
|
if (i < array.length) { |
|
result.push(array[i]); |
|
} |
|
} |
|
} |
|
return result; |
|
} |
|
|
|
|
|
|
|
|
|
class DnsResolver implements Resolver { |
|
private readonly ipResult: SubchannelAddress[] | null; |
|
private readonly dnsHostname: string | null; |
|
private readonly port: number | null; |
|
|
|
|
|
|
|
|
|
|
|
private readonly minTimeBetweenResolutionsMs: number; |
|
private pendingLookupPromise: Promise<dns.LookupAddress[]> | null = null; |
|
private pendingTxtPromise: Promise<string[][]> | null = null; |
|
private latestLookupResult: TcpSubchannelAddress[] | null = null; |
|
private latestServiceConfig: ServiceConfig | null = null; |
|
private latestServiceConfigError: StatusObject | null = null; |
|
private percentage: number; |
|
private defaultResolutionError: StatusObject; |
|
private backoff: BackoffTimeout; |
|
private continueResolving = false; |
|
private nextResolutionTimer: NodeJS.Timeout; |
|
private isNextResolutionTimerRunning = false; |
|
private isServiceConfigEnabled = true; |
|
private returnedIpResult = false; |
|
constructor( |
|
private target: GrpcUri, |
|
private listener: ResolverListener, |
|
channelOptions: ChannelOptions |
|
) { |
|
trace('Resolver constructed for target ' + uriToString(target)); |
|
const hostPort = splitHostPort(target.path); |
|
if (hostPort === null) { |
|
this.ipResult = null; |
|
this.dnsHostname = null; |
|
this.port = null; |
|
} else { |
|
if (isIPv4(hostPort.host) || isIPv6(hostPort.host)) { |
|
this.ipResult = [ |
|
{ |
|
host: hostPort.host, |
|
port: hostPort.port ?? DEFAULT_PORT, |
|
}, |
|
]; |
|
this.dnsHostname = null; |
|
this.port = null; |
|
} else { |
|
this.ipResult = null; |
|
this.dnsHostname = hostPort.host; |
|
this.port = hostPort.port ?? DEFAULT_PORT; |
|
} |
|
} |
|
this.percentage = Math.random() * 100; |
|
|
|
if (channelOptions['grpc.service_config_disable_resolution'] === 1) { |
|
this.isServiceConfigEnabled = false; |
|
} |
|
|
|
this.defaultResolutionError = { |
|
code: Status.UNAVAILABLE, |
|
details: `Name resolution failed for target ${uriToString(this.target)}`, |
|
metadata: new Metadata(), |
|
}; |
|
|
|
const backoffOptions: BackoffOptions = { |
|
initialDelay: channelOptions['grpc.initial_reconnect_backoff_ms'], |
|
maxDelay: channelOptions['grpc.max_reconnect_backoff_ms'], |
|
}; |
|
|
|
this.backoff = new BackoffTimeout(() => { |
|
if (this.continueResolving) { |
|
this.startResolutionWithBackoff(); |
|
} |
|
}, backoffOptions); |
|
this.backoff.unref(); |
|
|
|
this.minTimeBetweenResolutionsMs = |
|
channelOptions['grpc.dns_min_time_between_resolutions_ms'] ?? |
|
DEFAULT_MIN_TIME_BETWEEN_RESOLUTIONS_MS; |
|
this.nextResolutionTimer = setTimeout(() => {}, 0); |
|
clearTimeout(this.nextResolutionTimer); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
private startResolution() { |
|
if (this.ipResult !== null) { |
|
if (!this.returnedIpResult) { |
|
trace('Returning IP address for target ' + uriToString(this.target)); |
|
setImmediate(() => { |
|
this.listener.onSuccessfulResolution( |
|
this.ipResult!, |
|
null, |
|
null, |
|
null, |
|
{} |
|
); |
|
}); |
|
this.returnedIpResult = true; |
|
} |
|
this.backoff.stop(); |
|
this.backoff.reset(); |
|
this.stopNextResolutionTimer(); |
|
return; |
|
} |
|
if (this.dnsHostname === null) { |
|
trace('Failed to parse DNS address ' + uriToString(this.target)); |
|
setImmediate(() => { |
|
this.listener.onError({ |
|
code: Status.UNAVAILABLE, |
|
details: `Failed to parse DNS address ${uriToString(this.target)}`, |
|
metadata: new Metadata(), |
|
}); |
|
}); |
|
this.stopNextResolutionTimer(); |
|
} else { |
|
if (this.pendingLookupPromise !== null) { |
|
return; |
|
} |
|
trace('Looking up DNS hostname ' + this.dnsHostname); |
|
|
|
|
|
|
|
|
|
|
|
|
|
this.latestLookupResult = null; |
|
const hostname: string = this.dnsHostname; |
|
|
|
|
|
|
|
|
|
this.pendingLookupPromise = dnsLookupPromise(hostname, { all: true }); |
|
this.pendingLookupPromise.then( |
|
addressList => { |
|
if (this.pendingLookupPromise === null) { |
|
return; |
|
} |
|
this.pendingLookupPromise = null; |
|
this.backoff.reset(); |
|
this.backoff.stop(); |
|
const ip4Addresses: dns.LookupAddress[] = addressList.filter( |
|
addr => addr.family === 4 |
|
); |
|
const ip6Addresses: dns.LookupAddress[] = addressList.filter( |
|
addr => addr.family === 6 |
|
); |
|
this.latestLookupResult = mergeArrays(ip6Addresses, ip4Addresses).map( |
|
addr => ({ host: addr.address, port: +this.port! }) |
|
); |
|
const allAddressesString: string = |
|
'[' + |
|
this.latestLookupResult |
|
.map(addr => addr.host + ':' + addr.port) |
|
.join(',') + |
|
']'; |
|
trace( |
|
'Resolved addresses for target ' + |
|
uriToString(this.target) + |
|
': ' + |
|
allAddressesString |
|
); |
|
if (this.latestLookupResult.length === 0) { |
|
this.listener.onError(this.defaultResolutionError); |
|
return; |
|
} |
|
|
|
|
|
|
|
|
|
this.listener.onSuccessfulResolution( |
|
this.latestLookupResult, |
|
this.latestServiceConfig, |
|
this.latestServiceConfigError, |
|
null, |
|
{} |
|
); |
|
}, |
|
err => { |
|
if (this.pendingLookupPromise === null) { |
|
return; |
|
} |
|
trace( |
|
'Resolution error for target ' + |
|
uriToString(this.target) + |
|
': ' + |
|
(err as Error).message |
|
); |
|
this.pendingLookupPromise = null; |
|
this.stopNextResolutionTimer(); |
|
this.listener.onError(this.defaultResolutionError); |
|
} |
|
); |
|
|
|
|
|
if (this.isServiceConfigEnabled && this.pendingTxtPromise === null) { |
|
|
|
|
|
|
|
this.pendingTxtPromise = resolveTxtPromise(hostname); |
|
this.pendingTxtPromise.then( |
|
txtRecord => { |
|
if (this.pendingTxtPromise === null) { |
|
return; |
|
} |
|
this.pendingTxtPromise = null; |
|
try { |
|
this.latestServiceConfig = extractAndSelectServiceConfig( |
|
txtRecord, |
|
this.percentage |
|
); |
|
} catch (err) { |
|
this.latestServiceConfigError = { |
|
code: Status.UNAVAILABLE, |
|
details: `Parsing service config failed with error ${ |
|
(err as Error).message |
|
}`, |
|
metadata: new Metadata(), |
|
}; |
|
} |
|
if (this.latestLookupResult !== null) { |
|
|
|
|
|
|
|
|
|
this.listener.onSuccessfulResolution( |
|
this.latestLookupResult, |
|
this.latestServiceConfig, |
|
this.latestServiceConfigError, |
|
null, |
|
{} |
|
); |
|
} |
|
}, |
|
err => { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} |
|
); |
|
} |
|
} |
|
} |
|
|
|
private startNextResolutionTimer() { |
|
clearTimeout(this.nextResolutionTimer); |
|
this.nextResolutionTimer = setTimeout(() => { |
|
this.stopNextResolutionTimer(); |
|
if (this.continueResolving) { |
|
this.startResolutionWithBackoff(); |
|
} |
|
}, this.minTimeBetweenResolutionsMs).unref?.(); |
|
this.isNextResolutionTimerRunning = true; |
|
} |
|
|
|
private stopNextResolutionTimer() { |
|
clearTimeout(this.nextResolutionTimer); |
|
this.isNextResolutionTimerRunning = false; |
|
} |
|
|
|
private startResolutionWithBackoff() { |
|
if (this.pendingLookupPromise === null) { |
|
this.continueResolving = false; |
|
this.backoff.runOnce(); |
|
this.startNextResolutionTimer(); |
|
this.startResolution(); |
|
} |
|
} |
|
|
|
updateResolution() { |
|
|
|
|
|
|
|
|
|
if (this.pendingLookupPromise === null) { |
|
if (this.isNextResolutionTimerRunning || this.backoff.isRunning()) { |
|
if (this.isNextResolutionTimerRunning) { |
|
trace('resolution update delayed by "min time between resolutions" rate limit'); |
|
} else { |
|
trace('resolution update delayed by backoff timer until ' + this.backoff.getEndTime().toISOString()); |
|
} |
|
this.continueResolving = true; |
|
} else { |
|
this.startResolutionWithBackoff(); |
|
} |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
destroy() { |
|
this.continueResolving = false; |
|
this.backoff.reset(); |
|
this.backoff.stop(); |
|
this.stopNextResolutionTimer(); |
|
this.pendingLookupPromise = null; |
|
this.pendingTxtPromise = null; |
|
this.latestLookupResult = null; |
|
this.latestServiceConfig = null; |
|
this.latestServiceConfigError = null; |
|
this.returnedIpResult = false; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
static getDefaultAuthority(target: GrpcUri): string { |
|
return target.path; |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
export function setup(): void { |
|
registerResolver('dns', DnsResolver); |
|
registerDefaultScheme('dns'); |
|
} |
|
|
|
export interface DnsUrl { |
|
host: string; |
|
port?: string; |
|
} |
|
|