/* * Copyright 2019 gRPC authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 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); } /** * The default TCP port to connect to if not explicitly specified in the target. */ 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); /** * Merge any number of arrays into a single alternating array * @param arrays */ function mergeArrays(...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; } /** * Resolver implementation that handles DNS names and IP addresses. */ class DnsResolver implements Resolver { private readonly ipResult: SubchannelAddress[] | null; private readonly dnsHostname: string | null; private readonly port: number | null; /** * Minimum time between resolutions, measured as the time between starting * successive resolution requests. Only applies to successful resolutions. * Failures are handled by the backoff timer. */ private readonly minTimeBetweenResolutionsMs: number; private pendingLookupPromise: Promise | null = null; private pendingTxtPromise: Promise | 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.Timer; private isNextResolutionTimerRunning = false; private isServiceConfigEnabled = true; 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); } /** * If the target is an IP address, just provide that address as a result. * Otherwise, initiate A, AAAA, and TXT lookups */ private startResolution() { if (this.ipResult !== null) { trace('Returning IP address for target ' + uriToString(this.target)); setImmediate(() => { this.listener.onSuccessfulResolution( this.ipResult!, null, null, null, {} ); }); this.backoff.stop(); this.backoff.reset(); 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); /* We clear out latestLookupResult here to ensure that it contains the * latest result since the last time we started resolving. That way, the * TXT resolution handler can use it, but only if it finishes second. We * don't clear out any previous service config results because it's * better to use a service config that's slightly out of date than to * revert to an effectively blank one. */ this.latestLookupResult = null; const hostname: string = this.dnsHostname; /* We lookup both address families here and then split them up later * because when looking up a single family, dns.lookup outputs an error * if the name exists but there are no records for that family, and that * error is indistinguishable from other kinds of errors */ this.pendingLookupPromise = dnsLookupPromise(hostname, { all: true }); this.pendingLookupPromise.then( (addressList) => { 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; } /* If the TXT lookup has not yet finished, both of the last two * arguments will be null, which is the equivalent of getting an * empty TXT response. When the TXT lookup does finish, its handler * can update the service config by using the same address list */ this.listener.onSuccessfulResolution( this.latestLookupResult, this.latestServiceConfig, this.latestServiceConfigError, null, {} ); }, (err) => { trace( 'Resolution error for target ' + uriToString(this.target) + ': ' + (err as Error).message ); this.pendingLookupPromise = null; this.stopNextResolutionTimer(); this.listener.onError(this.defaultResolutionError); } ); /* If there already is a still-pending TXT resolution, we can just use * that result when it comes in */ if (this.isServiceConfigEnabled && this.pendingTxtPromise === null) { /* We handle the TXT query promise differently than the others because * the name resolution attempt as a whole is a success even if the TXT * lookup fails */ this.pendingTxtPromise = resolveTxtPromise(hostname); this.pendingTxtPromise.then( (txtRecord) => { this.pendingTxtPromise = null; try { this.latestServiceConfig = extractAndSelectServiceConfig( txtRecord, this.percentage ); } catch (err) { this.latestServiceConfigError = { code: Status.UNAVAILABLE, details: 'Parsing service config failed', metadata: new Metadata(), }; } if (this.latestLookupResult !== null) { /* We rely here on the assumption that calling this function with * identical parameters will be essentialy idempotent, and calling * it with the same address list and a different service config * should result in a fast and seamless switchover. */ this.listener.onSuccessfulResolution( this.latestLookupResult, this.latestServiceConfig, this.latestServiceConfigError, null, {} ); } }, (err) => { /* If TXT lookup fails we should do nothing, which means that we * continue to use the result of the most recent successful lookup, * or the default null config object if there has never been a * successful lookup. We do not set the latestServiceConfigError * here because that is specifically used for response validation * errors. We still need to handle this error so that it does not * bubble up as an unhandled promise rejection. */ } ); } } } 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.startResolution(); this.backoff.runOnce(); this.startNextResolutionTimer(); } } updateResolution() { /* If there is a pending lookup, just let it finish. Otherwise, if the * nextResolutionTimer or backoff timer is running, set the * continueResolving flag to resolve when whichever of those timers * fires. Otherwise, start resolving immediately. */ if (this.pendingLookupPromise === null) { if (this.isNextResolutionTimerRunning || this.backoff.isRunning()) { this.continueResolving = true; } else { this.startResolutionWithBackoff(); } } } destroy() { this.continueResolving = false; this.backoff.stop(); this.stopNextResolutionTimer(); } /** * Get the default authority for the given target. For IP targets, that is * the IP address. For DNS targets, it is the hostname. * @param target */ static getDefaultAuthority(target: GrpcUri): string { return target.path; } } /** * Set up the DNS resolver class by registering it as the handler for the * "dns:" prefix and as the default resolver. */ export function setup(): void { registerResolver('dns', DnsResolver); registerDefaultScheme('dns'); } export interface DnsUrl { host: string; port?: string; }