UNPKG

13.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.Timer;
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 = channelOptions['grpc.dns_min_time_between_resolutions_ms'] ?? DEFAULT_MIN_TIME_BETWEEN_RESOLUTIONS_MS;
154 this.nextResolutionTimer = setTimeout(() => {}, 0);
155 clearTimeout(this.nextResolutionTimer);
156 }
157
158 /**
159 * If the target is an IP address, just provide that address as a result.
160 * Otherwise, initiate A, AAAA, and TXT lookups
161 */
162 private startResolution() {
163 if (this.ipResult !== null) {
164 trace('Returning IP address for target ' + uriToString(this.target));
165 setImmediate(() => {
166 this.listener.onSuccessfulResolution(
167 this.ipResult!,
168 null,
169 null,
170 null,
171 {}
172 );
173 });
174 this.backoff.stop();
175 this.backoff.reset();
176 return;
177 }
178 if (this.dnsHostname === null) {
179 trace('Failed to parse DNS address ' + uriToString(this.target));
180 setImmediate(() => {
181 this.listener.onError({
182 code: Status.UNAVAILABLE,
183 details: `Failed to parse DNS address ${uriToString(this.target)}`,
184 metadata: new Metadata(),
185 });
186 });
187 this.stopNextResolutionTimer();
188 } else {
189 if (this.pendingLookupPromise !== null) {
190 return;
191 }
192 trace('Looking up DNS hostname ' + this.dnsHostname);
193 /* We clear out latestLookupResult here to ensure that it contains the
194 * latest result since the last time we started resolving. That way, the
195 * TXT resolution handler can use it, but only if it finishes second. We
196 * don't clear out any previous service config results because it's
197 * better to use a service config that's slightly out of date than to
198 * revert to an effectively blank one. */
199 this.latestLookupResult = null;
200 const hostname: string = this.dnsHostname;
201 /* We lookup both address families here and then split them up later
202 * because when looking up a single family, dns.lookup outputs an error
203 * if the name exists but there are no records for that family, and that
204 * error is indistinguishable from other kinds of errors */
205 this.pendingLookupPromise = dnsLookupPromise(hostname, { all: true });
206 this.pendingLookupPromise.then(
207 (addressList) => {
208 this.pendingLookupPromise = null;
209 this.backoff.reset();
210 this.backoff.stop();
211 const ip4Addresses: dns.LookupAddress[] = addressList.filter(
212 (addr) => addr.family === 4
213 );
214 const ip6Addresses: dns.LookupAddress[] = addressList.filter(
215 (addr) => addr.family === 6
216 );
217 this.latestLookupResult = mergeArrays(
218 ip6Addresses,
219 ip4Addresses
220 ).map((addr) => ({ host: addr.address, port: +this.port! }));
221 const allAddressesString: string =
222 '[' +
223 this.latestLookupResult
224 .map((addr) => addr.host + ':' + addr.port)
225 .join(',') +
226 ']';
227 trace(
228 'Resolved addresses for target ' +
229 uriToString(this.target) +
230 ': ' +
231 allAddressesString
232 );
233 if (this.latestLookupResult.length === 0) {
234 this.listener.onError(this.defaultResolutionError);
235 return;
236 }
237 /* If the TXT lookup has not yet finished, both of the last two
238 * arguments will be null, which is the equivalent of getting an
239 * empty TXT response. When the TXT lookup does finish, its handler
240 * can update the service config by using the same address list */
241 this.listener.onSuccessfulResolution(
242 this.latestLookupResult,
243 this.latestServiceConfig,
244 this.latestServiceConfigError,
245 null,
246 {}
247 );
248 },
249 (err) => {
250 trace(
251 'Resolution error for target ' +
252 uriToString(this.target) +
253 ': ' +
254 (err as Error).message
255 );
256 this.pendingLookupPromise = null;
257 this.stopNextResolutionTimer();
258 this.listener.onError(this.defaultResolutionError);
259 }
260 );
261 /* If there already is a still-pending TXT resolution, we can just use
262 * that result when it comes in */
263 if (this.isServiceConfigEnabled && this.pendingTxtPromise === null) {
264 /* We handle the TXT query promise differently than the others because
265 * the name resolution attempt as a whole is a success even if the TXT
266 * lookup fails */
267 this.pendingTxtPromise = resolveTxtPromise(hostname);
268 this.pendingTxtPromise.then(
269 (txtRecord) => {
270 this.pendingTxtPromise = null;
271 try {
272 this.latestServiceConfig = extractAndSelectServiceConfig(
273 txtRecord,
274 this.percentage
275 );
276 } catch (err) {
277 this.latestServiceConfigError = {
278 code: Status.UNAVAILABLE,
279 details: 'Parsing service config failed',
280 metadata: new Metadata(),
281 };
282 }
283 if (this.latestLookupResult !== null) {
284 /* We rely here on the assumption that calling this function with
285 * identical parameters will be essentialy idempotent, and calling
286 * it with the same address list and a different service config
287 * should result in a fast and seamless switchover. */
288 this.listener.onSuccessfulResolution(
289 this.latestLookupResult,
290 this.latestServiceConfig,
291 this.latestServiceConfigError,
292 null,
293 {}
294 );
295 }
296 },
297 (err) => {
298 /* If TXT lookup fails we should do nothing, which means that we
299 * continue to use the result of the most recent successful lookup,
300 * or the default null config object if there has never been a
301 * successful lookup. We do not set the latestServiceConfigError
302 * here because that is specifically used for response validation
303 * errors. We still need to handle this error so that it does not
304 * bubble up as an unhandled promise rejection. */
305 }
306 );
307 }
308 }
309 }
310
311 private startNextResolutionTimer() {
312 clearTimeout(this.nextResolutionTimer);
313 this.nextResolutionTimer = setTimeout(() => {
314 this.stopNextResolutionTimer();
315 if (this.continueResolving) {
316 this.startResolutionWithBackoff();
317 }
318 }, this.minTimeBetweenResolutionsMs).unref?.();
319 this.isNextResolutionTimerRunning = true;
320 }
321
322 private stopNextResolutionTimer() {
323 clearTimeout(this.nextResolutionTimer);
324 this.isNextResolutionTimerRunning = false;
325 }
326
327 private startResolutionWithBackoff() {
328 if (this.pendingLookupPromise === null) {
329 this.continueResolving = false;
330 this.startResolution();
331 this.backoff.runOnce();
332 this.startNextResolutionTimer();
333 }
334 }
335
336 updateResolution() {
337 /* If there is a pending lookup, just let it finish. Otherwise, if the
338 * nextResolutionTimer or backoff timer is running, set the
339 * continueResolving flag to resolve when whichever of those timers
340 * fires. Otherwise, start resolving immediately. */
341 if (this.pendingLookupPromise === null) {
342 if (this.isNextResolutionTimerRunning || this.backoff.isRunning()) {
343 this.continueResolving = true;
344 } else {
345 this.startResolutionWithBackoff();
346 }
347 }
348 }
349
350 destroy() {
351 this.continueResolving = false;
352 this.backoff.stop();
353 this.stopNextResolutionTimer();
354 }
355
356 /**
357 * Get the default authority for the given target. For IP targets, that is
358 * the IP address. For DNS targets, it is the hostname.
359 * @param target
360 */
361 static getDefaultAuthority(target: GrpcUri): string {
362 return target.path;
363 }
364}
365
366/**
367 * Set up the DNS resolver class by registering it as the handler for the
368 * "dns:" prefix and as the default resolver.
369 */
370export function setup(): void {
371 registerResolver('dns', DnsResolver);
372 registerDefaultScheme('dns');
373}
374
375export interface DnsUrl {
376 host: string;
377 port?: string;
378}