UNPKG

14.2 kBPlain TextView Raw
1/*
2 * Copyright 2019 gRPC authors.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import {
18 Resolver,
19 ResolverListener,
20 registerResolver,
21 registerDefaultScheme,
22} from './resolver';
23import * as dns from 'dns';
24import * as util from 'util';
25import { extractAndSelectServiceConfig, ServiceConfig } from './service-config';
26import { Status } from './constants';
27import { StatusObject } from './call-interface';
28import { Metadata } from './metadata';
29import * as logging from './logging';
30import { LogVerbosity } from './constants';
31import { SubchannelAddress, TcpSubchannelAddress } from './subchannel-address';
32import { GrpcUri, uriToString, splitHostPort } from './uri-parser';
33import { isIPv6, isIPv4 } from 'net';
34import { ChannelOptions } from './channel-options';
35import { BackoffOptions, BackoffTimeout } from './backoff-timeout';
36
37const TRACER_NAME = 'dns_resolver';
38
39function trace(text: string): void {
40 logging.trace(LogVerbosity.DEBUG, TRACER_NAME, text);
41}
42
43/**
44 * The default TCP port to connect to if not explicitly specified in the target.
45 */
46const DEFAULT_PORT = 443;
47
48const DEFAULT_MIN_TIME_BETWEEN_RESOLUTIONS_MS = 30_000;
49
50const resolveTxtPromise = util.promisify(dns.resolveTxt);
51const dnsLookupPromise = util.promisify(dns.lookup);
52
53/**
54 * Merge any number of arrays into a single alternating array
55 * @param arrays
56 */
57function mergeArrays<T>(...arrays: T[][]): T[] {
58 const result: T[] = [];
59 for (
60 let i = 0;
61 i <
62 Math.max.apply(
63 null,
64 arrays.map(array => array.length)
65 );
66 i++
67 ) {
68 for (const array of arrays) {
69 if (i < array.length) {
70 result.push(array[i]);
71 }
72 }
73 }
74 return result;
75}
76
77/**
78 * Resolver implementation that handles DNS names and IP addresses.
79 */
80class DnsResolver implements Resolver {
81 private readonly ipResult: SubchannelAddress[] | null;
82 private readonly dnsHostname: string | null;
83 private readonly port: number | null;
84 /**
85 * Minimum time between resolutions, measured as the time between starting
86 * successive resolution requests. Only applies to successful resolutions.
87 * Failures are handled by the backoff timer.
88 */
89 private readonly minTimeBetweenResolutionsMs: number;
90 private pendingLookupPromise: Promise<dns.LookupAddress[]> | null = null;
91 private pendingTxtPromise: Promise<string[][]> | null = null;
92 private latestLookupResult: TcpSubchannelAddress[] | null = null;
93 private latestServiceConfig: ServiceConfig | null = null;
94 private latestServiceConfigError: StatusObject | null = null;
95 private percentage: number;
96 private defaultResolutionError: StatusObject;
97 private backoff: BackoffTimeout;
98 private continueResolving = false;
99 private nextResolutionTimer: NodeJS.Timeout;
100 private isNextResolutionTimerRunning = false;
101 private isServiceConfigEnabled = true;
102 constructor(
103 private target: GrpcUri,
104 private listener: ResolverListener,
105 channelOptions: ChannelOptions
106 ) {
107 trace('Resolver constructed for target ' + uriToString(target));
108 const hostPort = splitHostPort(target.path);
109 if (hostPort === null) {
110 this.ipResult = null;
111 this.dnsHostname = null;
112 this.port = null;
113 } else {
114 if (isIPv4(hostPort.host) || isIPv6(hostPort.host)) {
115 this.ipResult = [
116 {
117 host: hostPort.host,
118 port: hostPort.port ?? DEFAULT_PORT,
119 },
120 ];
121 this.dnsHostname = null;
122 this.port = null;
123 } else {
124 this.ipResult = null;
125 this.dnsHostname = hostPort.host;
126 this.port = hostPort.port ?? DEFAULT_PORT;
127 }
128 }
129 this.percentage = Math.random() * 100;
130
131 if (channelOptions['grpc.service_config_disable_resolution'] === 1) {
132 this.isServiceConfigEnabled = false;
133 }
134
135 this.defaultResolutionError = {
136 code: Status.UNAVAILABLE,
137 details: `Name resolution failed for target ${uriToString(this.target)}`,
138 metadata: new Metadata(),
139 };
140
141 const backoffOptions: BackoffOptions = {
142 initialDelay: channelOptions['grpc.initial_reconnect_backoff_ms'],
143 maxDelay: channelOptions['grpc.max_reconnect_backoff_ms'],
144 };
145
146 this.backoff = new BackoffTimeout(() => {
147 if (this.continueResolving) {
148 this.startResolutionWithBackoff();
149 }
150 }, backoffOptions);
151 this.backoff.unref();
152
153 this.minTimeBetweenResolutionsMs =
154 channelOptions['grpc.dns_min_time_between_resolutions_ms'] ??
155 DEFAULT_MIN_TIME_BETWEEN_RESOLUTIONS_MS;
156 this.nextResolutionTimer = setTimeout(() => {}, 0);
157 clearTimeout(this.nextResolutionTimer);
158 }
159
160 /**
161 * If the target is an IP address, just provide that address as a result.
162 * Otherwise, initiate A, AAAA, and TXT lookups
163 */
164 private startResolution() {
165 if (this.ipResult !== null) {
166 trace('Returning IP address for target ' + uriToString(this.target));
167 setImmediate(() => {
168 this.listener.onSuccessfulResolution(
169 this.ipResult!,
170 null,
171 null,
172 null,
173 {}
174 );
175 });
176 this.backoff.stop();
177 this.backoff.reset();
178 this.stopNextResolutionTimer();
179 return;
180 }
181 if (this.dnsHostname === null) {
182 trace('Failed to parse DNS address ' + uriToString(this.target));
183 setImmediate(() => {
184 this.listener.onError({
185 code: Status.UNAVAILABLE,
186 details: `Failed to parse DNS address ${uriToString(this.target)}`,
187 metadata: new Metadata(),
188 });
189 });
190 this.stopNextResolutionTimer();
191 } else {
192 if (this.pendingLookupPromise !== null) {
193 return;
194 }
195 trace('Looking up DNS hostname ' + this.dnsHostname);
196 /* We clear out latestLookupResult here to ensure that it contains the
197 * latest result since the last time we started resolving. That way, the
198 * TXT resolution handler can use it, but only if it finishes second. We
199 * don't clear out any previous service config results because it's
200 * better to use a service config that's slightly out of date than to
201 * revert to an effectively blank one. */
202 this.latestLookupResult = null;
203 const hostname: string = this.dnsHostname;
204 /* We lookup both address families here and then split them up later
205 * because when looking up a single family, dns.lookup outputs an error
206 * if the name exists but there are no records for that family, and that
207 * error is indistinguishable from other kinds of errors */
208 this.pendingLookupPromise = dnsLookupPromise(hostname, { all: true });
209 this.pendingLookupPromise.then(
210 addressList => {
211 if (this.pendingLookupPromise === null) {
212 return;
213 }
214 this.pendingLookupPromise = null;
215 this.backoff.reset();
216 this.backoff.stop();
217 const ip4Addresses: dns.LookupAddress[] = addressList.filter(
218 addr => addr.family === 4
219 );
220 const ip6Addresses: dns.LookupAddress[] = addressList.filter(
221 addr => addr.family === 6
222 );
223 this.latestLookupResult = mergeArrays(ip6Addresses, ip4Addresses).map(
224 addr => ({ host: addr.address, port: +this.port! })
225 );
226 const allAddressesString: string =
227 '[' +
228 this.latestLookupResult
229 .map(addr => addr.host + ':' + addr.port)
230 .join(',') +
231 ']';
232 trace(
233 'Resolved addresses for target ' +
234 uriToString(this.target) +
235 ': ' +
236 allAddressesString
237 );
238 if (this.latestLookupResult.length === 0) {
239 this.listener.onError(this.defaultResolutionError);
240 return;
241 }
242 /* If the TXT lookup has not yet finished, both of the last two
243 * arguments will be null, which is the equivalent of getting an
244 * empty TXT response. When the TXT lookup does finish, its handler
245 * can update the service config by using the same address list */
246 this.listener.onSuccessfulResolution(
247 this.latestLookupResult,
248 this.latestServiceConfig,
249 this.latestServiceConfigError,
250 null,
251 {}
252 );
253 },
254 err => {
255 if (this.pendingLookupPromise === null) {
256 return;
257 }
258 trace(
259 'Resolution error for target ' +
260 uriToString(this.target) +
261 ': ' +
262 (err as Error).message
263 );
264 this.pendingLookupPromise = null;
265 this.stopNextResolutionTimer();
266 this.listener.onError(this.defaultResolutionError);
267 }
268 );
269 /* If there already is a still-pending TXT resolution, we can just use
270 * that result when it comes in */
271 if (this.isServiceConfigEnabled && this.pendingTxtPromise === null) {
272 /* We handle the TXT query promise differently than the others because
273 * the name resolution attempt as a whole is a success even if the TXT
274 * lookup fails */
275 this.pendingTxtPromise = resolveTxtPromise(hostname);
276 this.pendingTxtPromise.then(
277 txtRecord => {
278 if (this.pendingTxtPromise === null) {
279 return;
280 }
281 this.pendingTxtPromise = null;
282 try {
283 this.latestServiceConfig = extractAndSelectServiceConfig(
284 txtRecord,
285 this.percentage
286 );
287 } catch (err) {
288 this.latestServiceConfigError = {
289 code: Status.UNAVAILABLE,
290 details: `Parsing service config failed with error ${
291 (err as Error).message
292 }`,
293 metadata: new Metadata(),
294 };
295 }
296 if (this.latestLookupResult !== null) {
297 /* We rely here on the assumption that calling this function with
298 * identical parameters will be essentialy idempotent, and calling
299 * it with the same address list and a different service config
300 * should result in a fast and seamless switchover. */
301 this.listener.onSuccessfulResolution(
302 this.latestLookupResult,
303 this.latestServiceConfig,
304 this.latestServiceConfigError,
305 null,
306 {}
307 );
308 }
309 },
310 err => {
311 /* If TXT lookup fails we should do nothing, which means that we
312 * continue to use the result of the most recent successful lookup,
313 * or the default null config object if there has never been a
314 * successful lookup. We do not set the latestServiceConfigError
315 * here because that is specifically used for response validation
316 * errors. We still need to handle this error so that it does not
317 * bubble up as an unhandled promise rejection. */
318 }
319 );
320 }
321 }
322 }
323
324 private startNextResolutionTimer() {
325 clearTimeout(this.nextResolutionTimer);
326 this.nextResolutionTimer = setTimeout(() => {
327 this.stopNextResolutionTimer();
328 if (this.continueResolving) {
329 this.startResolutionWithBackoff();
330 }
331 }, this.minTimeBetweenResolutionsMs).unref?.();
332 this.isNextResolutionTimerRunning = true;
333 }
334
335 private stopNextResolutionTimer() {
336 clearTimeout(this.nextResolutionTimer);
337 this.isNextResolutionTimerRunning = false;
338 }
339
340 private startResolutionWithBackoff() {
341 if (this.pendingLookupPromise === null) {
342 this.continueResolving = false;
343 this.backoff.runOnce();
344 this.startNextResolutionTimer();
345 this.startResolution();
346 }
347 }
348
349 updateResolution() {
350 /* If there is a pending lookup, just let it finish. Otherwise, if the
351 * nextResolutionTimer or backoff timer is running, set the
352 * continueResolving flag to resolve when whichever of those timers
353 * fires. Otherwise, start resolving immediately. */
354 if (this.pendingLookupPromise === null) {
355 if (this.isNextResolutionTimerRunning || this.backoff.isRunning()) {
356 if (this.isNextResolutionTimerRunning) {
357 trace('resolution update delayed by "min time between resolutions" rate limit');
358 } else {
359 trace('resolution update delayed by backoff timer until ' + this.backoff.getEndTime().toISOString());
360 }
361 this.continueResolving = true;
362 } else {
363 this.startResolutionWithBackoff();
364 }
365 }
366 }
367
368 /**
369 * Reset the resolver to the same state it had when it was created. In-flight
370 * DNS requests cannot be cancelled, but they are discarded and their results
371 * will be ignored.
372 */
373 destroy() {
374 this.continueResolving = false;
375 this.backoff.reset();
376 this.backoff.stop();
377 this.stopNextResolutionTimer();
378 this.pendingLookupPromise = null;
379 this.pendingTxtPromise = null;
380 this.latestLookupResult = null;
381 this.latestServiceConfig = null;
382 this.latestServiceConfigError = null;
383 }
384
385 /**
386 * Get the default authority for the given target. For IP targets, that is
387 * the IP address. For DNS targets, it is the hostname.
388 * @param target
389 */
390 static getDefaultAuthority(target: GrpcUri): string {
391 return target.path;
392 }
393}
394
395/**
396 * Set up the DNS resolver class by registering it as the handler for the
397 * "dns:" prefix and as the default resolver.
398 */
399export function setup(): void {
400 registerResolver('dns', DnsResolver);
401 registerDefaultScheme('dns');
402}
403
404export interface DnsUrl {
405 host: string;
406 port?: string;
407}