Merge pull request #1243 from murgatroid99/grpc-js_proxy_support

grpc-js: Add HTTP CONNECT support, i.e. egress proxy support
This commit is contained in:
Michael Lumish 2020-02-28 11:17:45 -08:00 committed by GitHub
commit 8cf49ca1bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 204 additions and 5 deletions

View File

@ -0,0 +1,162 @@
/*
* 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 { URL, parse } 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';
import { SubchannelAddress, TcpSubchannelAddress, isTcpSubchannelAddress } from "./subchannel";
const TRACER_NAME = 'proxy';
function trace(text: string): void {
logging.trace(LogVerbosity.DEBUG, TRACER_NAME, text);
}
interface ProxyInfo {
address?: string;
creds?: string;
}
function getProxyInfo(): ProxyInfo {
let proxyEnv: string = '';
let envVar: string = '';
/* Prefer using 'grpc_proxy'. Fallback on 'http_proxy' if it is not set.
* Also prefer using 'https_proxy' with fallback on 'http_proxy'. The
* fallback behavior can be removed if there's a demand for it.
*/
if (process.env.grpc_proxy) {
envVar = 'grpc_proxy';
proxyEnv = process.env.grpc_proxy;
} else if (process.env.https_proxy) {
envVar = 'https_proxy';
proxyEnv = process.env.https_proxy;
} else if (process.env.http_proxy) {
envVar = 'http_proxy';
proxyEnv = process.env.http_proxy;
} else {
return {};
}
let proxyUrl: URL;
try {
proxyUrl = new URL(proxyEnv);
} catch (e) {
log(LogVerbosity.INFO, `cannot parse value of "${envVar}" env var`);
return {};
}
if (proxyUrl.protocol !== 'http') {
log(LogVerbosity.ERROR, `"${proxyUrl.protocol}" scheme not supported in proxy URI`);
return {};
}
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;
}
}
const result: ProxyInfo = {
address: proxyUrl.host
};
if (userCred) {
result.creds = userCred;
}
trace('Proxy server ' + result.address + ' set by environment variable ' + envVar);
return result;
}
const PROXY_INFO = getProxyInfo();
function getNoProxyHostList(): string[] {
/* Prefer using 'no_grpc_proxy'. Fallback on 'no_proxy' if it is not set. */
let noProxyStr: string | undefined = process.env.no_grpc_proxy;
let envVar: string = 'no_grpc_proxy';
if (!noProxyStr) {
noProxyStr = process.env.no_proxy;
envVar = 'no_proxy';
}
if (noProxyStr) {
trace('No proxy server list set by environment variable ' + envVar);
return noProxyStr.split(',');
} else {
return [];
}
}
const NO_PROXY_HOSTS = getNoProxyHostList();
export function shouldUseProxy(target: string): boolean {
if (!PROXY_INFO.address) {
return false;
}
let serverHost: string;
const parsedTarget = parseTarget(target);
if (parsedTarget) {
serverHost = parsedTarget.host;
} else {
return false;
}
for (const host of NO_PROXY_HOSTS) {
if (host === serverHost) {
trace('Not using proxy for target in no_proxy list: ' + target);
return false;
}
}
return true;
}
export function getProxiedConnection(target: string, subchannelAddress: SubchannelAddress): Promise<Socket> {
if (!(PROXY_INFO.address && shouldUseProxy(target) && isTcpSubchannelAddress(subchannelAddress))) {
return Promise.reject<Socket>();
}
const subchannelAddressPathString = `${subchannelAddress.host}:${subchannelAddress.port}`;
trace('Using proxy ' + PROXY_INFO.address + ' to connect to ' + target + ' at ' + subchannelAddress);
const options: http.RequestOptions = {
method: 'CONNECT',
host: PROXY_INFO.address,
path: subchannelAddressPathString
};
if (PROXY_INFO.creds) {
options.headers = {
'Proxy-Authorization': 'Basic ' + Buffer.from(PROXY_INFO.creds).toString('base64')
};
}
return new Promise<Socket>((resolve, reject) => {
const request = http.request(options);
request.once('connect', (res, socket, head) => {
request.removeAllListeners();
socket.removeAllListeners();
if (res.statusCode === http.STATUS_CODES.OK) {
trace('Successfully connected to ' + subchannelAddress + ' through proxy ' + PROXY_INFO.address);
resolve(socket);
} else {
trace('Failed to connect to ' + subchannelAddress + ' through proxy ' + PROXY_INFO.address);
reject();
}
});
request.once('error', (err) => {
request.removeAllListeners();
trace('Failed to connect to proxy ' + PROXY_INFO.address);
reject();
});
});
}

View File

@ -328,3 +328,20 @@ export function setup(): void {
registerResolver('dns:', DnsResolver); registerResolver('dns:', DnsResolver);
registerDefaultResolver(DnsResolver); registerDefaultResolver(DnsResolver);
} }
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;
}
}

View File

@ -26,6 +26,7 @@ import { BackoffTimeout, BackoffOptions } from './backoff-timeout';
import { getDefaultAuthority } from './resolver'; import { getDefaultAuthority } from './resolver';
import * as logging from './logging'; import * as logging from './logging';
import { LogVerbosity } from './constants'; import { LogVerbosity } from './constants';
import { shouldUseProxy, getProxiedConnection } from './http_proxy';
import * as net from 'net'; import * as net from 'net';
const { version: clientVersion } = require('../../package.json'); const { version: clientVersion } = require('../../package.json');
@ -277,7 +278,7 @@ export class Subchannel {
clearTimeout(this.keepaliveTimeoutId); clearTimeout(this.keepaliveTimeoutId);
} }
private startConnectingInternal() { private createSession(socket?: net.Socket) {
let connectionOptions: http2.SecureClientSessionOptions = let connectionOptions: http2.SecureClientSessionOptions =
this.credentials._getConnectionOptions() || {}; this.credentials._getConnectionOptions() || {};
let addressScheme = 'http://'; let addressScheme = 'http://';
@ -300,15 +301,22 @@ export class Subchannel {
} else { } else {
connectionOptions.servername = getDefaultAuthority(this.channelTarget); connectionOptions.servername = getDefaultAuthority(this.channelTarget);
} }
if (socket) {
connectionOptions.socket = socket;
}
} else { } else {
/* In all but the most recent versions of Node, http2.connect does not use /* In all but the most recent versions of Node, http2.connect does not use
* the options when establishing plaintext connections, so we need to * the options when establishing plaintext connections, so we need to
* establish that connection explicitly. */ * establish that connection explicitly. */
connectionOptions.createConnection = (authority, option) => { connectionOptions.createConnection = (authority, option) => {
/* net.NetConnectOpts is declared in a way that is more restrictive if (socket) {
* than what net.connect will actually accept, so we use the type return socket;
* assertion to work around that. */ } else {
return net.connect(this.subchannelAddress as net.NetConnectOpts); /* net.NetConnectOpts is declared in a way that is more restrictive
* than what net.connect will actually accept, so we use the type
* assertion to work around that. */
return net.connect(this.subchannelAddress);
}
}; };
} }
connectionOptions = Object.assign( connectionOptions = Object.assign(
@ -402,6 +410,18 @@ export class Subchannel {
}); });
} }
private startConnectingInternal() {
if (shouldUseProxy(this.channelTarget)) {
getProxiedConnection(this.channelTarget, this.subchannelAddress).then((socket) => {
this.createSession(socket);
}, (reason) => {
this.transitionToState([ConnectivityState.CONNECTING], ConnectivityState.TRANSIENT_FAILURE);
});
} else {
this.createSession();
}
}
/** /**
* Initiate a state transition from any element of oldStates to the new * Initiate a state transition from any element of oldStates to the new
* state. If the current connectivityState is not in oldStates, do nothing. * state. If the current connectivityState is not in oldStates, do nothing.