UNPKG

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