From 13cc016e4e59777d398e6735fcbaef398eab5ee8 Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Wed, 15 Apr 2020 18:04:49 -0700 Subject: [PATCH 1/4] grpc-js: Use a more structured representation of URIs internally --- packages/grpc-js/src/channel.ts | 20 ++- packages/grpc-js/src/http_proxy.ts | 59 ++++----- packages/grpc-js/src/resolver-dns.ts | 121 ++++-------------- packages/grpc-js/src/resolver-uds.ts | 27 ++-- packages/grpc-js/src/resolver.ts | 49 ++++--- .../grpc-js/src/resolving-load-balancer.ts | 3 +- packages/grpc-js/src/server.ts | 8 +- packages/grpc-js/src/subchannel-pool.ts | 7 +- packages/grpc-js/src/subchannel.ts | 3 +- packages/grpc-js/src/uri-parser.ts | 114 +++++++++++++++++ packages/grpc-js/test/test-resolver.ts | 35 ++--- packages/grpc-js/test/test-uri-parser.ts | 59 +++++++++ 12 files changed, 317 insertions(+), 188 deletions(-) create mode 100644 packages/grpc-js/src/uri-parser.ts create mode 100644 packages/grpc-js/test/test-uri-parser.ts diff --git a/packages/grpc-js/src/channel.ts b/packages/grpc-js/src/channel.ts index 96397b70..a9a82ac0 100644 --- a/packages/grpc-js/src/channel.ts +++ b/packages/grpc-js/src/channel.ts @@ -39,6 +39,7 @@ import { trace, log } from './logging'; import { SubchannelAddress } from './subchannel'; import { MaxMessageSizeFilterFactory } from './max-message-size-filter'; import { mapProxyName } from './http_proxy'; +import { GrpcUri, parseUri, uriToString } from './uri-parser'; export enum ConnectivityState { CONNECTING, @@ -136,8 +137,9 @@ export class ChannelImplementation implements Channel { private connectivityStateWatchers: ConnectivityStateWatcher[] = []; private defaultAuthority: string; private filterStackFactory: FilterStackFactory; + private target: GrpcUri; constructor( - private target: string, + target: string, private readonly credentials: ChannelCredentials, private readonly options: ChannelOptions ) { @@ -164,14 +166,24 @@ export class ChannelImplementation implements Channel { ); } } + const originalTargetUri = parseUri(target); + if (originalTargetUri === null) { + throw new Error(`Could not parse target name "${target}"`); + } if (this.options['grpc.default_authority']) { this.defaultAuthority = this.options['grpc.default_authority'] as string; } else { - this.defaultAuthority = getDefaultAuthority(target); + this.defaultAuthority = getDefaultAuthority(originalTargetUri); } - const proxyMapResult = mapProxyName(target, options); + const proxyMapResult = mapProxyName(originalTargetUri, options); this.target = proxyMapResult.target; this.options = Object.assign({}, this.options, proxyMapResult.extraOptions); + + const targetUri = parseUri(target); + if (targetUri === null) { + throw new Error(`Could not parse target name "${target}"`); + } + this.target = targetUri; /* The global boolean parameter to getSubchannelPool has the inverse meaning to what * the grpc.use_local_subchannel_pool channel option means. */ this.subchannelPool = getSubchannelPool( @@ -422,7 +434,7 @@ export class ChannelImplementation implements Channel { } getTarget() { - return this.target; + return uriToString(this.target); } getConnectivityState(tryToConnect: boolean) { diff --git a/packages/grpc-js/src/http_proxy.ts b/packages/grpc-js/src/http_proxy.ts index 2b3fb5d5..e5e1ec3a 100644 --- a/packages/grpc-js/src/http_proxy.ts +++ b/packages/grpc-js/src/http_proxy.ts @@ -15,10 +15,8 @@ * */ -import { URL } from 'url'; import { log } from './logging'; import { LogVerbosity } from './constants'; -import { parseTarget } from './resolver-dns'; import { Socket } from 'net'; import * as http from 'http'; import * as logging from './logging'; @@ -28,6 +26,7 @@ import { subchannelAddressToString, } from './subchannel'; import { ChannelOptions } from './channel-options'; +import { GrpcUri, parseUri, splitHostPort, uriToString } from './uri-parser'; const TRACER_NAME = 'proxy'; @@ -59,31 +58,30 @@ function getProxyInfo(): ProxyInfo { } else { return {}; } - let proxyUrl: URL; - try { - proxyUrl = new URL(proxyEnv); - } catch (e) { + const proxyUrl = parseUri(proxyEnv); + if (proxyUrl === null) { log(LogVerbosity.ERROR, `cannot parse value of "${envVar}" env var`); return {}; } - if (proxyUrl.protocol !== 'http:') { + if (proxyUrl.scheme !== 'http') { log( LogVerbosity.ERROR, - `"${proxyUrl.protocol}" scheme not supported in proxy URI` + `"${proxyUrl.scheme}" scheme not supported in proxy URI` ); return {}; } + const splitPath = proxyUrl.path.split('@'); + let host: string; let userCred: string | null = null; - if (proxyUrl.username) { - if (proxyUrl.password) { - log(LogVerbosity.INFO, 'userinfo found in proxy URI'); - userCred = `${proxyUrl.username}:${proxyUrl.password}`; - } else { - userCred = proxyUrl.username; - } + if (splitPath.length === 2) { + log(LogVerbosity.INFO, 'userinfo found in proxy URI'); + userCred = splitPath[0]; + host = splitPath[1]; + } else { + host = proxyUrl.path; } const result: ProxyInfo = { - address: proxyUrl.host, + address: host, }; if (userCred) { result.creds = userCred; @@ -111,12 +109,12 @@ function getNoProxyHostList(): string[] { } export interface ProxyMapResult { - target: string; + target: GrpcUri; extraOptions: ChannelOptions; } export function mapProxyName( - target: string, + target: GrpcUri, options: ChannelOptions ): ProxyMapResult { const noProxyResult: ProxyMapResult = { @@ -127,11 +125,11 @@ export function mapProxyName( if (!proxyInfo.address) { return noProxyResult; } - const parsedTarget = parseTarget(target); - if (!parsedTarget) { + const hostPort = splitHostPort(target.path); + if (!hostPort) { return noProxyResult; } - const serverHost = parsedTarget.host; + const serverHost = hostPort.host; for (const host of getNoProxyHostList()) { if (host === serverHost) { trace('Not using proxy for target in no_proxy list: ' + target); @@ -139,20 +137,20 @@ export function mapProxyName( } } const extraOptions: ChannelOptions = { - 'grpc.http_connect_target': target, + 'grpc.http_connect_target': uriToString(target), }; if (proxyInfo.creds) { extraOptions['grpc.http_connect_creds'] = proxyInfo.creds; } return { - target: `dns:${proxyInfo.address}`, + target: { path: proxyInfo.address }, extraOptions: extraOptions, }; } export interface ProxyConnectionResult { socket?: Socket; - realTarget?: string; + realTarget?: GrpcUri; } export function getProxiedConnection( @@ -163,9 +161,13 @@ export function getProxiedConnection( return Promise.resolve({}); } const realTarget = channelOptions['grpc.http_connect_target'] as string; - const parsedTarget = parseTarget(realTarget)!; + const parsedTarget = parseUri(realTarget); + if (parsedTarget === null) { + return Promise.resolve({}); + } const options: http.RequestOptions = { method: 'CONNECT', + path: parsedTarget.path, }; // Connect to the subchannel address as a proxy if (isTcpSubchannelAddress(address)) { @@ -174,11 +176,6 @@ export function getProxiedConnection( } else { options.socketPath = address.path; } - if (parsedTarget.port === undefined) { - options.path = parsedTarget.host; - } else { - options.path = `${parsedTarget.host}:${parsedTarget.port}`; - } if ('grpc.http_connect_creds' in channelOptions) { options.headers = { 'Proxy-Authorization': @@ -204,7 +201,7 @@ export function getProxiedConnection( ); resolve({ socket, - realTarget, + realTarget: parsedTarget, }); } else { log( diff --git a/packages/grpc-js/src/resolver-dns.ts b/packages/grpc-js/src/resolver-dns.ts index 24aa0236..1f97c655 100644 --- a/packages/grpc-js/src/resolver-dns.ts +++ b/packages/grpc-js/src/resolver-dns.ts @@ -29,6 +29,8 @@ import { Metadata } from './metadata'; import * as logging from './logging'; import { LogVerbosity } from './constants'; import { SubchannelAddress, TcpSubchannelAddress } from './subchannel'; +import { GrpcUri, uriToString, splitHostPort } from './uri-parser'; +import { isIPv6, isIPv4 } from 'net'; const TRACER_NAME = 'dns_resolver'; @@ -36,67 +38,14 @@ function trace(text: string): void { logging.trace(LogVerbosity.DEBUG, TRACER_NAME, text); } -/* These regular expressions match IP addresses with optional ports in different - * formats. In each case, capture group 1 contains the address, and capture - * group 2 contains the port number, if present */ -/** - * Matches 4 groups of up to 3 digits each, separated by periods, optionally - * followed by a colon and a number. - */ -const IPV4_REGEX = /^(\d{1,3}(?:\.\d{1,3}){3})(?::(\d+))?$/; -/** - * Matches any number of groups of up to 4 hex digits (case insensitive) - * separated by 1 or more colons. This variant does not match a port number. - */ -const IPV6_REGEX = /^([0-9a-f]{0,4}(?::{1,2}[0-9a-f]{0,4})+)$/i; -/** - * Matches the same as the IPv6_REGEX, surrounded by square brackets, and - * optionally followed by a colon and a number. - */ -const IPV6_BRACKET_REGEX = /^\[([0-9a-f]{0,4}(?::{1,2}[0-9a-f]{0,4})+)\](?::(\d+))?$/i; - -/** - * Matches `[dns:][//authority/]host[:port]`, where `authority` and `host` are - * both arbitrary sequences of dot-separated strings of alphanumeric characters - * and `port` is a sequence of digits. Group 1 contains the hostname and group - * 2 contains the port number if provided. - */ -const DNS_REGEX = /^(?:dns:)?(?:\/\/(?:[a-zA-Z0-9-]+\.?)+\/)?((?:[a-zA-Z0-9-]+\.?)+)(?::(\d+))?$/; - /** * The default TCP port to connect to if not explicitly specified in the target. */ -const DEFAULT_PORT = '443'; +const DEFAULT_PORT = 443; const resolveTxtPromise = util.promisify(dns.resolveTxt); const dnsLookupPromise = util.promisify(dns.lookup); -/** - * Attempt to parse a target string as an IP address - * @param target - * @return An "IP:port" string in an array if parsing was successful, `null` otherwise - */ -function parseIP(target: string): SubchannelAddress[] | null { - /* These three regular expressions are all mutually exclusive, so we just - * want the first one that matches the target string, if any do. */ - const ipv4Match = IPV4_REGEX.exec(target); - const match = - ipv4Match || IPV6_REGEX.exec(target) || IPV6_BRACKET_REGEX.exec(target); - if (match === null) { - return null; - } - - // ipv6 addresses should be bracketed - const addr = match[1]; - let port: string; - if (match[2]) { - port = match[2]; - } else { - port = DEFAULT_PORT; - } - return [{ host: addr, port: +port }]; -} - /** * Merge any number of arrays into a single alternating array * @param arrays @@ -127,7 +76,7 @@ function mergeArrays(...arrays: T[][]): T[] { class DnsResolver implements Resolver { private readonly ipResult: SubchannelAddress[] | null; private readonly dnsHostname: string | null; - private readonly port: string | null; + private readonly port: number | null; private pendingLookupPromise: Promise | null = null; private pendingTxtPromise: Promise | null = null; private latestLookupResult: TcpSubchannelAddress[] | null = null; @@ -135,19 +84,27 @@ class DnsResolver implements Resolver { private latestServiceConfigError: StatusObject | null = null; private percentage: number; private defaultResolutionError: StatusObject; - constructor(private target: string, private listener: ResolverListener) { - trace('Resolver constructed for target ' + target); - this.ipResult = parseIP(target); - const dnsMatch = DNS_REGEX.exec(target); - if (dnsMatch === null) { + constructor(private target: GrpcUri, private listener: ResolverListener) { + 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 { - this.dnsHostname = dnsMatch[1]; - if (dnsMatch[2]) { - this.port = dnsMatch[2]; + 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.port = DEFAULT_PORT; + this.ipResult = null; + this.dnsHostname = hostPort.host; + this.port = hostPort.port ?? DEFAULT_PORT; } } this.percentage = Math.random() * 100; @@ -308,19 +265,13 @@ class DnsResolver implements Resolver { * the IP address. For DNS targets, it is the hostname. * @param target */ - static getDefaultAuthority(target: string): string { - const ipMatch = - IPV4_REGEX.exec(target) || - IPV6_REGEX.exec(target) || - IPV6_BRACKET_REGEX.exec(target); - if (ipMatch) { - return ipMatch[1]; + static getDefaultAuthority(target: GrpcUri): string { + const hostPort = splitHostPort(target.path); + if (hostPort !== null) { + return hostPort.host; + } else { + throw new Error(`Failed to parse target ${target}`); } - const dnsMatch = DNS_REGEX.exec(target); - if (dnsMatch) { - return dnsMatch[1]; - } - throw new Error(`Failed to parse target ${target}`); } } @@ -329,7 +280,7 @@ class DnsResolver implements Resolver { * "dns:" prefix and as the default resolver. */ export function setup(): void { - registerResolver('dns:', DnsResolver); + registerResolver('dns', DnsResolver); registerDefaultResolver(DnsResolver); } @@ -337,19 +288,3 @@ export interface DnsUrl { host: string; port?: string; } - -export function parseTarget(target: string): DnsUrl | null { - const match = - IPV4_REGEX.exec(target) ?? - IPV6_REGEX.exec(target) ?? - IPV6_BRACKET_REGEX.exec(target) ?? - DNS_REGEX.exec(target); - if (match) { - return { - host: match[1], - port: match[2] ?? undefined, - }; - } else { - return null; - } -} diff --git a/packages/grpc-js/src/resolver-uds.ts b/packages/grpc-js/src/resolver-uds.ts index 91128d2c..c147f637 100644 --- a/packages/grpc-js/src/resolver-uds.ts +++ b/packages/grpc-js/src/resolver-uds.ts @@ -21,23 +21,18 @@ import { registerDefaultResolver, } from './resolver'; import { SubchannelAddress } from './subchannel'; - -function getUdsName(target: string): string { - /* Due to how this resolver is registered, it should only be constructed - * with strings that start with 'unix:'. Other strings may result in - * nonsensical output. If the string starts with 'unix://' that entire - * prefix needs to be ignored */ - if (target.startsWith('unix://')) { - return target.substring(7); - } else { - return target.substring(5); - } -} +import { GrpcUri } from './uri-parser'; class UdsResolver implements Resolver { private addresses: SubchannelAddress[] = []; - constructor(target: string, private listener: ResolverListener) { - this.addresses = [{ path: getUdsName(target) }]; + constructor(target: GrpcUri, private listener: ResolverListener) { + let path: string; + if (target.authority === '') { + path = '/' + target.path; + } else { + path = target.path; + } + this.addresses = [{ path }]; } updateResolution(): void { process.nextTick( @@ -48,11 +43,11 @@ class UdsResolver implements Resolver { ); } - static getDefaultAuthority(target: string): string { + static getDefaultAuthority(target: GrpcUri): string { return 'localhost'; } } export function setup() { - registerResolver('unix:', UdsResolver); + registerResolver('unix', UdsResolver); } diff --git a/packages/grpc-js/src/resolver.ts b/packages/grpc-js/src/resolver.ts index 4c091752..e75f178a 100644 --- a/packages/grpc-js/src/resolver.ts +++ b/packages/grpc-js/src/resolver.ts @@ -20,6 +20,7 @@ import * as resolver_dns from './resolver-dns'; import * as resolver_uds from './resolver-uds'; import { StatusObject } from './call-stream'; import { SubchannelAddress } from './subchannel'; +import { GrpcUri, uriToString } from './uri-parser'; /** * A listener object passed to the resolver's constructor that provides name @@ -62,17 +63,17 @@ export interface Resolver { } export interface ResolverConstructor { - new (target: string, listener: ResolverListener): Resolver; + new (target: GrpcUri, listener: ResolverListener): Resolver; /** * Get the default authority for a target. This loosely corresponds to that * target's hostname. Throws an error if this resolver class cannot parse the * `target`. * @param target */ - getDefaultAuthority(target: string): string; + getDefaultAuthority(target: GrpcUri): string; } -const registeredResolvers: { [prefix: string]: ResolverConstructor } = {}; +const registeredResolvers: { [scheme: string]: ResolverConstructor } = {}; let defaultResolver: ResolverConstructor | null = null; /** @@ -83,10 +84,10 @@ let defaultResolver: ResolverConstructor | null = null; * @param resolverClass */ export function registerResolver( - prefix: string, + scheme: string, resolverClass: ResolverConstructor ) { - registeredResolvers[prefix] = resolverClass; + registeredResolvers[scheme] = resolverClass; } /** @@ -105,18 +106,24 @@ export function registerDefaultResolver(resolverClass: ResolverConstructor) { * @param listener */ export function createResolver( - target: string, + target: GrpcUri, listener: ResolverListener ): Resolver { - for (const prefix of Object.keys(registeredResolvers)) { - if (target.startsWith(prefix)) { - return new registeredResolvers[prefix](target, listener); + if (target.scheme !== undefined && target.scheme in registeredResolvers) { + return new registeredResolvers[target.scheme](target, listener); + } else { + if (defaultResolver !== null) { + /* If the scheme does not correspond to a registered scheme, we assume + * that the whole thing is the path, and the scheme was pulled out + * incorrectly. For example, it is valid to parse "localhost:80" as + * having a scheme of "localhost" and a path of 80, but that is not + * how the resolver should see it */ + return new defaultResolver({ path: uriToString(target) }, listener); } } - if (defaultResolver !== null) { - return new defaultResolver(target, listener); - } - throw new Error(`No resolver could be created for target ${target}`); + throw new Error( + `No resolver could be created for target ${uriToString(target)}` + ); } /** @@ -124,16 +131,16 @@ export function createResolver( * error if no registered name resolver can parse that target string. * @param target */ -export function getDefaultAuthority(target: string): string { - for (const prefix of Object.keys(registeredResolvers)) { - if (target.startsWith(prefix)) { - return registeredResolvers[prefix].getDefaultAuthority(target); +export function getDefaultAuthority(target: GrpcUri): string { + if (target.scheme !== undefined && target.scheme in registeredResolvers) { + return registeredResolvers[target.scheme].getDefaultAuthority(target); + } else { + if (defaultResolver !== null) { + // See comment in createResolver for why we handle the target like this + return defaultResolver.getDefaultAuthority({ path: uriToString(target) }); } } - if (defaultResolver !== null) { - return defaultResolver.getDefaultAuthority(target); - } - throw new Error(`Invalid target ${target}`); + throw new Error(`Invalid target ${uriToString(target)}`); } export function registerAll() { diff --git a/packages/grpc-js/src/resolving-load-balancer.ts b/packages/grpc-js/src/resolving-load-balancer.ts index a9ba0594..0720e7c0 100644 --- a/packages/grpc-js/src/resolving-load-balancer.ts +++ b/packages/grpc-js/src/resolving-load-balancer.ts @@ -35,6 +35,7 @@ import { Metadata } from './metadata'; import * as logging from './logging'; import { LogVerbosity } from './constants'; import { SubchannelAddress } from './subchannel'; +import { GrpcUri } from './uri-parser'; const TRACER_NAME = 'resolving_load_balancer'; @@ -126,7 +127,7 @@ export class ResolvingLoadBalancer implements LoadBalancer { * implmentation */ constructor( - private target: string, + private target: GrpcUri, private channelControlHelper: ChannelControlHelper, private defaultServiceConfig: ServiceConfig | null ) { diff --git a/packages/grpc-js/src/server.ts b/packages/grpc-js/src/server.ts index 25656a3b..95aa68a2 100644 --- a/packages/grpc-js/src/server.ts +++ b/packages/grpc-js/src/server.ts @@ -52,6 +52,7 @@ import { TcpSubchannelAddress, isTcpSubchannelAddress, } from './subchannel'; +import { parseUri } from './uri-parser'; interface BindResult { port: number; @@ -225,6 +226,11 @@ export class Server { throw new TypeError('callback must be a function'); } + const portUri = parseUri(port); + if (portUri === null) { + throw new Error(`Could not parse port "${port}"`); + } + const serverOptions: http2.ServerOptions = {}; if ('grpc.max_concurrent_streams' in this.options) { serverOptions.settings = { @@ -392,7 +398,7 @@ export class Server { }, }; - const resolver = createResolver(port, resolverListener); + const resolver = createResolver(portUri, resolverListener); resolver.updateResolution(); } diff --git a/packages/grpc-js/src/subchannel-pool.ts b/packages/grpc-js/src/subchannel-pool.ts index 0d42163d..d28e3eac 100644 --- a/packages/grpc-js/src/subchannel-pool.ts +++ b/packages/grpc-js/src/subchannel-pool.ts @@ -22,6 +22,7 @@ import { subchannelAddressEqual, } from './subchannel'; import { ChannelCredentials } from './channel-credentials'; +import { GrpcUri, uriToString } from './uri-parser'; // 10 seconds in milliseconds. This value is arbitrary. /** @@ -114,13 +115,13 @@ export class SubchannelPool { * @param channelCredentials */ getOrCreateSubchannel( - channelTarget: string, + channelTargetUri: GrpcUri, subchannelTarget: SubchannelAddress, channelArguments: ChannelOptions, channelCredentials: ChannelCredentials ): Subchannel { this.ensureCleanupTask(); - + const channelTarget = uriToString(channelTargetUri); if (channelTarget in this.pool) { const subchannelObjArray = this.pool[channelTarget]; for (const subchannelObj of subchannelObjArray) { @@ -141,7 +142,7 @@ export class SubchannelPool { } // If we get here, no matching subchannel was found const subchannel = new Subchannel( - channelTarget, + channelTargetUri, subchannelTarget, channelArguments, channelCredentials diff --git a/packages/grpc-js/src/subchannel.ts b/packages/grpc-js/src/subchannel.ts index e332df36..bbb2937f 100644 --- a/packages/grpc-js/src/subchannel.ts +++ b/packages/grpc-js/src/subchannel.ts @@ -28,6 +28,7 @@ import * as logging from './logging'; import { LogVerbosity } from './constants'; import { getProxiedConnection, ProxyConnectionResult } from './http_proxy'; import * as net from 'net'; +import { GrpcUri } from './uri-parser'; const clientVersion = require('../../package.json').version; @@ -199,7 +200,7 @@ export class Subchannel { * connection */ constructor( - private channelTarget: string, + private channelTarget: GrpcUri, private subchannelAddress: SubchannelAddress, private options: ChannelOptions, private credentials: ChannelCredentials diff --git a/packages/grpc-js/src/uri-parser.ts b/packages/grpc-js/src/uri-parser.ts new file mode 100644 index 00000000..20c3d53b --- /dev/null +++ b/packages/grpc-js/src/uri-parser.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2020 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. + * + */ + +export interface GrpcUri { + scheme?: string; + authority?: string; + path: string; +} + +/* + * The groups correspond to URI parts as follows: + * 1. scheme + * 2. authority + * 3. path + */ +const URI_REGEX = /^(?:([A-Za-z0-9+.-]+):)?(?:\/\/([^/]*)\/)?(.+)$/; + +export function parseUri(uriString: string): GrpcUri | null { + const parsedUri = URI_REGEX.exec(uriString); + if (parsedUri === null) { + return null; + } + return { + scheme: parsedUri[1], + authority: parsedUri[2], + path: parsedUri[3], + }; +} + +export interface HostPort { + host: string; + port?: number; +} + +const NUMBER_REGEX = /^\d+$/; + +export function splitHostPort(path: string): HostPort | null { + if (path.startsWith('[')) { + const hostEnd = path.indexOf(']'); + if (hostEnd === -1) { + return null; + } + const host = path.substring(1, hostEnd); + /* Only an IPv6 address should be in bracketed notation, and an IPv6 + * address should have at least one colon */ + if (host.indexOf(':') === -1) { + return null; + } + if (path.length > hostEnd + 1) { + if (path[hostEnd + 1] === ':') { + const portString = path.substring(hostEnd + 2); + if (NUMBER_REGEX.test(portString)) { + return { + host: host, + port: +portString, + }; + } else { + return null; + } + } else { + return null; + } + } else { + return { + host, + }; + } + } else { + const splitPath = path.split(':'); + /* Exactly one colon means that this is host:port. Zero colons means that + * there is no port. And multiple colons means that this is a bare IPv6 + * address with no port */ + if (splitPath.length === 2) { + if (NUMBER_REGEX.test(splitPath[1])) { + return { + host: splitPath[0], + port: +splitPath[1], + }; + } else { + return null; + } + } else { + return { + host: path, + }; + } + } +} + +export function uriToString(uri: GrpcUri): string { + let result = ''; + if (uri.scheme !== undefined) { + result += uri.scheme + ':'; + } + if (uri.authority !== undefined) { + result += '//' + uri.authority + '/'; + } + result += uri.path; + return result; +} diff --git a/packages/grpc-js/test/test-resolver.ts b/packages/grpc-js/test/test-resolver.ts index 7f4900aa..465964ae 100644 --- a/packages/grpc-js/test/test-resolver.ts +++ b/packages/grpc-js/test/test-resolver.ts @@ -22,6 +22,7 @@ import * as resolverManager from '../src/resolver'; import { ServiceConfig } from '../src/service-config'; import { StatusObject } from '../src/call-stream'; import { SubchannelAddress, isTcpSubchannelAddress } from '../src/subchannel'; +import { parseUri, GrpcUri } from '../src/uri-parser'; describe('Name Resolver', () => { describe('DNS Names', function() { @@ -31,7 +32,7 @@ describe('Name Resolver', () => { resolverManager.registerAll(); }); it('Should resolve localhost properly', done => { - const target = 'localhost:50051'; + const target = parseUri('localhost:50051')!; const listener: resolverManager.ResolverListener = { onSuccessfulResolution: ( addressList: SubchannelAddress[], @@ -66,7 +67,7 @@ describe('Name Resolver', () => { resolver.updateResolution(); }); it('Should default to port 443', done => { - const target = 'localhost'; + const target = parseUri('localhost')!; const listener: resolverManager.ResolverListener = { onSuccessfulResolution: ( addressList: SubchannelAddress[], @@ -101,7 +102,7 @@ describe('Name Resolver', () => { resolver.updateResolution(); }); it('Should correctly represent an ipv4 address', done => { - const target = '1.2.3.4'; + const target = parseUri('1.2.3.4')!; const listener: resolverManager.ResolverListener = { onSuccessfulResolution: ( addressList: SubchannelAddress[], @@ -128,7 +129,7 @@ describe('Name Resolver', () => { resolver.updateResolution(); }); it('Should correctly represent an ipv6 address', done => { - const target = '::1'; + const target = parseUri('::1')!; const listener: resolverManager.ResolverListener = { onSuccessfulResolution: ( addressList: SubchannelAddress[], @@ -155,7 +156,7 @@ describe('Name Resolver', () => { resolver.updateResolution(); }); it('Should correctly represent a bracketed ipv6 address', done => { - const target = '[::1]:50051'; + const target = parseUri('[::1]:50051')!; const listener: resolverManager.ResolverListener = { onSuccessfulResolution: ( addressList: SubchannelAddress[], @@ -182,7 +183,7 @@ describe('Name Resolver', () => { resolver.updateResolution(); }); it('Should resolve a public address', done => { - const target = 'example.com'; + const target = parseUri('example.com')!; const listener: resolverManager.ResolverListener = { onSuccessfulResolution: ( addressList: SubchannelAddress[], @@ -202,7 +203,7 @@ describe('Name Resolver', () => { resolver.updateResolution(); }); it('Should resolve a name with multiple dots', done => { - const target = 'loopback4.unittest.grpc.io'; + const target = parseUri('loopback4.unittest.grpc.io')!; const listener: resolverManager.ResolverListener = { onSuccessfulResolution: ( addressList: SubchannelAddress[], @@ -231,7 +232,7 @@ describe('Name Resolver', () => { /* TODO(murgatroid99): re-enable this test, once we can get the IPv6 result * consistently */ it.skip('Should resolve a DNS name to an IPv6 address', done => { - const target = 'loopback6.unittest.grpc.io'; + const target = parseUri('loopback6.unittest.grpc.io')!; const listener: resolverManager.ResolverListener = { onSuccessfulResolution: ( addressList: SubchannelAddress[], @@ -258,7 +259,7 @@ describe('Name Resolver', () => { resolver.updateResolution(); }); it('Should resolve a DNS name to IPv4 and IPv6 addresses', done => { - const target = 'loopback46.unittest.grpc.io'; + const target = parseUri('loopback46.unittest.grpc.io')!; const listener: resolverManager.ResolverListener = { onSuccessfulResolution: ( addressList: SubchannelAddress[], @@ -289,7 +290,7 @@ describe('Name Resolver', () => { it('Should resolve a name with a hyphen', done => { /* TODO(murgatroid99): Find or create a better domain name to test this with. * This is just the first one I found with a hyphen. */ - const target = 'network-tools.com'; + const target = parseUri('network-tools.com')!; const listener: resolverManager.ResolverListener = { onSuccessfulResolution: ( addressList: SubchannelAddress[], @@ -310,8 +311,8 @@ describe('Name Resolver', () => { }); it('Should resolve gRPC interop servers', done => { let completeCount = 0; - const target1 = 'grpc-test.sandbox.googleapis.com'; - const target2 = 'grpc-test4.sandbox.googleapis.com'; + const target1 = parseUri('grpc-test.sandbox.googleapis.com')!; + const target2 = parseUri('grpc-test4.sandbox.googleapis.com')!; const listener: resolverManager.ResolverListener = { onSuccessfulResolution: ( addressList: SubchannelAddress[], @@ -332,13 +333,13 @@ describe('Name Resolver', () => { }; const resolver1 = resolverManager.createResolver(target1, listener); resolver1.updateResolution(); - const resolver2 = resolverManager.createResolver(target1, listener); + const resolver2 = resolverManager.createResolver(target2, listener); resolver2.updateResolution(); }); }); describe('UDS Names', () => { it('Should handle a relative Unix Domain Socket name', done => { - const target = 'unix:socket'; + const target = parseUri('unix:socket')!; const listener: resolverManager.ResolverListener = { onSuccessfulResolution: ( addressList: SubchannelAddress[], @@ -362,7 +363,7 @@ describe('Name Resolver', () => { resolver.updateResolution(); }); it('Should handle an absolute Unix Domain Socket name', done => { - const target = 'unix:///tmp/socket'; + const target = parseUri('unix:///tmp/socket')!; const listener: resolverManager.ResolverListener = { onSuccessfulResolution: ( addressList: SubchannelAddress[], @@ -393,13 +394,13 @@ describe('Name Resolver', () => { return []; } - static getDefaultAuthority(target: string): string { + static getDefaultAuthority(target: GrpcUri): string { return 'other'; } } it('Should return the correct authority if a different resolver has been registered', () => { - const target = 'other://name'; + const target = parseUri('other://name')!; resolverManager.registerResolver('other:', OtherResolver); const authority = resolverManager.getDefaultAuthority(target); diff --git a/packages/grpc-js/test/test-uri-parser.ts b/packages/grpc-js/test/test-uri-parser.ts new file mode 100644 index 00000000..75aa82bf --- /dev/null +++ b/packages/grpc-js/test/test-uri-parser.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2020 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 * as assert from 'assert'; +import * as uriParser from '../src/uri-parser'; + +describe('URI Parser', function(){ + describe('parseUri', function() { + const expectationList: {target: string, result: uriParser.GrpcUri | null}[] = [ + {target: 'localhost', result: {scheme: undefined, authority: undefined, path: 'localhost'}}, + /* This looks weird, but it's OK because the resolver selection code will handle it */ + {target: 'localhost:80', result: {scheme: 'localhost', authority: undefined, path: '80'}}, + {target: 'dns:localhost', result: {scheme: 'dns', authority: undefined, path: 'localhost'}}, + {target: 'dns:///localhost', result: {scheme: 'dns', authority: '', path: 'localhost'}}, + {target: 'dns://authority/localhost', result: {scheme: 'dns', authority: 'authority', path: 'localhost'}}, + {target: '//authority/localhost', result: {scheme: undefined, authority: 'authority', path: 'localhost'}}, + // Regression test for https://github.com/grpc/grpc-node/issues/1359 + {target: 'dns:foo-internal.aws-us-east-2.tracing.staging-edge.foo-data.net:443:443', result: {scheme: 'dns', authority: undefined, path: 'foo-internal.aws-us-east-2.tracing.staging-edge.foo-data.net:443:443'}} + ]; + for (const {target, result} of expectationList) { + it (target, function() { + assert.deepStrictEqual(uriParser.parseUri(target), result); + }); + } + }); + + describe('splitHostPort', function() { + const expectationList: {path: string, result: uriParser.HostPort | null}[] = [ + {path: 'localhost', result: {host: 'localhost'}}, + {path: 'localhost:123', result: {host: 'localhost', port: 123}}, + {path: '12345:6789', result: {host: '12345', port: 6789}}, + {path: '[::1]:123', result: {host: '::1', port: 123}}, + {path: '[::1]', result: {host: '::1'}}, + {path: '[', result: null}, + {path: '[123]', result: null}, + // Regression test for https://github.com/grpc/grpc-node/issues/1359 + {path: 'foo-internal.aws-us-east-2.tracing.staging-edge.foo-data.net:443:443', result: {host: 'foo-internal.aws-us-east-2.tracing.staging-edge.foo-data.net:443:443'}} + ]; + for (const {path, result} of expectationList) { + it(path, function() { + assert.deepStrictEqual(uriParser.splitHostPort(path), result); + }); + } + }); +}); \ No newline at end of file From 1cd0ef12f22ed58d8b183931c44679d7eae79f20 Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Thu, 16 Apr 2020 07:45:29 -0700 Subject: [PATCH 2/4] Fix target name in "other" resolver test --- packages/grpc-js/test/test-resolver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grpc-js/test/test-resolver.ts b/packages/grpc-js/test/test-resolver.ts index 465964ae..160a40d9 100644 --- a/packages/grpc-js/test/test-resolver.ts +++ b/packages/grpc-js/test/test-resolver.ts @@ -400,7 +400,7 @@ describe('Name Resolver', () => { } it('Should return the correct authority if a different resolver has been registered', () => { - const target = parseUri('other://name')!; + const target = parseUri('other:name')!; resolverManager.registerResolver('other:', OtherResolver); const authority = resolverManager.getDefaultAuthority(target); From dbe98ad09056e5e1c28793c9d70395d086e01a62 Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Thu, 16 Apr 2020 08:34:24 -0700 Subject: [PATCH 3/4] Fix "other" resovler registration code --- packages/grpc-js/src/resolver-dns.ts | 2 +- packages/grpc-js/test/test-resolver.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/grpc-js/src/resolver-dns.ts b/packages/grpc-js/src/resolver-dns.ts index 1f97c655..58cc6dc8 100644 --- a/packages/grpc-js/src/resolver-dns.ts +++ b/packages/grpc-js/src/resolver-dns.ts @@ -270,7 +270,7 @@ class DnsResolver implements Resolver { if (hostPort !== null) { return hostPort.host; } else { - throw new Error(`Failed to parse target ${target}`); + throw new Error(`Failed to parse target ${uriToString(target)}`); } } } diff --git a/packages/grpc-js/test/test-resolver.ts b/packages/grpc-js/test/test-resolver.ts index 160a40d9..0ff40ac1 100644 --- a/packages/grpc-js/test/test-resolver.ts +++ b/packages/grpc-js/test/test-resolver.ts @@ -401,7 +401,8 @@ describe('Name Resolver', () => { it('Should return the correct authority if a different resolver has been registered', () => { const target = parseUri('other:name')!; - resolverManager.registerResolver('other:', OtherResolver); + console.log(target); + resolverManager.registerResolver('other', OtherResolver); const authority = resolverManager.getDefaultAuthority(target); assert.equal(authority, 'other'); From 98e46260ef0e77a1052a2c51ca468de60e7eb4b1 Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Mon, 20 Apr 2020 11:22:56 -0700 Subject: [PATCH 4/4] Fix merge error with proxy fixes --- packages/grpc-js/src/http_proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grpc-js/src/http_proxy.ts b/packages/grpc-js/src/http_proxy.ts index 726c84c1..2721055f 100644 --- a/packages/grpc-js/src/http_proxy.ts +++ b/packages/grpc-js/src/http_proxy.ts @@ -213,7 +213,7 @@ export function getProxiedConnection( * See https://github.com/grpc/grpc-node/pull/1369 for more info. */ const cts = tls.connect({ ...connectionOptions, - host: getDefaultAuthority(realTarget), + host: getDefaultAuthority(parsedTarget), socket: socket, }, () => { resolve({ socket: cts, realTarget: parsedTarget });