Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

grpc-js: initiate tls connection through http proxy #1369

Merged
merged 9 commits into from
Apr 20, 2020

Conversation

mrfelton
Copy link
Contributor

Trying to get ssl connection to establish correctly when connecting via proxy.

In reference to #992

realTarget,
var cts = tls.connect({
...connectionOptions,
host: options.host,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

options.host here is the IP address of the proxy, and when socket is set, host is only used to validate the server certificates. Why would you want to validate the server certificates against the proxy's IP address?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. Also works without setting host - have pushed update that removes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I think you want host: getDefaultAuthority(realTarget) here, so that you don't have to pass the grpc.ssl_target_name_override option when constructing the client. In fact, TLS certificate validation is the whole reason we pass the default authority to http2.connect in the first place.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

@@ -302,21 +302,20 @@ export class Subchannel {
if (proxyConnectionResult.socket) {
connectionOptions.socket = proxyConnectionResult.socket;
Copy link
Member

@murgatroid99 murgatroid99 Apr 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With your changes, this line should do nothing because the createConnection option below takes precedence.

/* 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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing this unconditionally breaks the unproxied TLS case, because you are forcing the use of a plaintext connection here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not worry about this for the moment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have updated this so that we still have the plaintext conditional

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have the exact same createConnection function in the secure and insecure case. The problem was, and is, that you're always doing net.connect, which creates a plain TCP connection. In the secure case, you should only be using the createConnection to pass in the socket from the proxy.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section of the change now doesn't functionally change anything, so I'd rather not change it at all.

@mrfelton
Copy link
Contributor Author

Do I need to do something special to get all of the tests to pass? I get the following results:

  123 passing (587ms)
  2 pending
  27 failing

This is the same result I get in master branch.

@murgatroid99
Copy link
Member

You should undo that last commit. Using the target name override there is correct, that option just shouldn't be necessary in most cases. In general, when using the grpc-js API, you should not be passing the grpc.ssl_target_name_override option at all; it is mainly good for testing.

@murgatroid99
Copy link
Member

I've been making these small corrections, but overall I still don't understand how this change could cause any difference in behavior from what the existing code does. It looks to me like it is simply pulling out part of the behavior of http2.connect into the proxy handling code.

@mrfelton
Copy link
Contributor Author

To be honest, I as hoping you'd be able to explain it! Somehow this forces the tls connection to the real target to be established.

@mrfelton
Copy link
Contributor Author

I've pushed another update to this. I think this resolves the issues in your previous comments.

My basic understanding is that when you initially set up the connection to the proxy it doesn't support tls, but if the proxy is connecting to a TLS server the socket needs upgrading to a TLS connection.

Does that makes sense fro your perspective?

@murgatroid99
Copy link
Member

The thing is that http2.connect already calls tls.connect internally with the options passed to it if it's connecting to an https address. So the original code that passes the socket from the proxy in the http2.connect options should be doing the exact same thing that this code is doing.

@mrfelton
Copy link
Contributor Author

I know in theory that is supposed to be the case, but in practice it doesn't. Or something about the way we are calling http2.connect is preventing that from working as it should.

connectionOptions.socket = proxyConnectionResult.socket;
return proxyConnectionResult.socket;
} else if ('secureContext' in connectionOptions) {
return tls.connect(this.subchannelAddress);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't better because you're not passing in the options. When connecting with TLS and without a proxy, it should simply not be passing a createConnection option at all.

Copy link
Contributor Author

@mrfelton mrfelton Apr 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My typescript is not up to par :( What I really wanted to do in the ssl block was like this:

      if (proxyConnectionResult.socket) {
        connectionOptions.createConnection = (authority, option) => {
          return proxyConnectionResult.socket;
        };
      }

But I get typescript errors.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, the issue is that TypeScript thinks that proxyConnectionResult.socket might become null between when you check it and when the callback is called. This should work:

if (proxyConnectionResult.socket) {
  connectionOptions.createConnection = (authority, option) => {
    return proxyConnectionResult.socket!; //Exclamation mark here
  };
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one! updated.

@murgatroid99
Copy link
Member

murgatroid99 commented Apr 18, 2020

I'm not talking about "in theory", I'm talking about the http2.connect source code

@mrfelton
Copy link
Contributor Author

I'm not talking about "in theory", I'm talking about the http2.connect source code

Understood - but in reality it simply doesn't work the way it was.

@murgatroid99
Copy link
Member

So, one difference between what you're doing here and what http.connect is doing is that initializeTLSOptions function call before passing the options to tls.connect. What if you copy that function (and the initializeOptions function that it calls) and call it the same way in the proxy code? Does your request through TOR still work?

@mrfelton
Copy link
Contributor Author

Yes, the tor connection works correctly even after wrapping my tls.connect options in initializeTLSOptions

@mrfelton
Copy link
Contributor Author

mrfelton commented Apr 18, 2020

If I call like this it still works:

          const cts = tls.connect(Number(options.port), getDefaultAuthority(realTarget), initializeTLSOptions({ ...connectionOptions, socket}, getDefaultAuthority(realTarget)), () => {
            resolve({ socket: cts, realTarget });
          });

So wrapping the options in initializeTLSOptions doesn't seem to make a difference here.

@murgatroid99
Copy link
Member

I have another idea: log calls to tls.connect by monkey patching it like this:

const originalTlsConnect = tls.connect;
tls.connect = (...args) => {
  console.log(args);
  return originalTlsConnect(...args);
};

Then check what the output is for your test case, both with this change (including the version with initializeTlsOptions, to minimize confounding factors) and with the original code.

@mrfelton
Copy link
Contributor Author

My version (works):

[
  9065,
  'zapn34qfeedw2l5y26p3hnnkusqnbhxcxw64lq5cojmvq45yw4bc3sqd.onion',
  {
    secureContext: SecureContext { context: SecureContext {} },
    socket: Socket {
      connecting: false,
      _hadError: false,
      _parent: null,
      _host: null,
      _readableState: [ReadableState],
      readable: true,
      _events: [Object: null prototype] {},
      _eventsCount: 0,
      _maxListeners: undefined,
      _writableState: [WritableState],
      writable: true,
      allowHalfOpen: false,
      _sockname: null,
      _pendingData: null,
      _pendingEncoding: '',
      server: null,
      _server: null,
      parser: null,
      _httpMessage: null,
      [Symbol(asyncId)]: 133,
      [Symbol(kHandle)]: [TCP],
      [Symbol(lastWriteQueueSize)]: 0,
      [Symbol(timeout)]: null,
      [Symbol(kBuffer)]: null,
      [Symbol(kBufferCb)]: null,
      [Symbol(kBufferGen)]: null,
      [Symbol(kBytesRead)]: 0,
      [Symbol(kBytesWritten)]: 0
    },
    settings: {},
    Http1IncomingMessage: [Function: IncomingMessage],
    Http1ServerResponse: [Function: ServerResponse],
    Http2ServerRequest: [Function: Http2ServerRequest],
    Http2ServerResponse: [Function: Http2ServerResponse],
    ALPNProtocols: [ 'h2' ],
    servername: 'zapn34qfeedw2l5y26p3hnnkusqnbhxcxw64lq5cojmvq45yw4bc3sqd.onion'
  },
  [Function]
]

Old version (doesn't work):

[
  '443',
  'zapn34qfeedw2l5y26p3hnnkusqnbhxcxw64lq5cojmvq45yw4bc3sqd.onion',
  {
    secureContext: SecureContext { context: SecureContext {} },
    socket: Socket {
      connecting: false,
      _hadError: false,
      _parent: null,
      _host: null,
      _readableState: [ReadableState],
      readable: true,
      _events: [Object: null prototype] {},
      _eventsCount: 0,
      _maxListeners: undefined,
      _writableState: [WritableState],
      writable: true,
      allowHalfOpen: false,
      _sockname: null,
      _pendingData: null,
      _pendingEncoding: '',
      server: null,
      _server: null,
      parser: null,
      _httpMessage: null,
      [Symbol(asyncId)]: 135,
      [Symbol(kHandle)]: [TCP],
      [Symbol(lastWriteQueueSize)]: 0,
      [Symbol(timeout)]: null,
      [Symbol(kBuffer)]: null,
      [Symbol(kBufferCb)]: null,
      [Symbol(kBufferGen)]: null,
      [Symbol(kBytesRead)]: 0,
      [Symbol(kBytesWritten)]: 0
    },
    host: '127.0.0.1',
    port: 9065,
    settings: {},
    Http1IncomingMessage: [Function: IncomingMessage],
    Http1ServerResponse: [Function: ServerResponse],
    Http2ServerRequest: [Function: Http2ServerRequest],
    Http2ServerResponse: [Function: Http2ServerResponse],
    ALPNProtocols: [ 'h2' ],
    servername: 'zapn34qfeedw2l5y26p3hnnkusqnbhxcxw64lq5cojmvq45yw4bc3sqd.onion'
  }
]

Different host and port

@murgatroid99
Copy link
Member

OK, it should be pretty simple to change that: in the unmodified code, just put this line in a if (!proxyConnectionResult.socket) block. Does that make your code work?

@mrfelton
Copy link
Contributor Author

Unfortunately not. I have also tried other things to make the tls.connect from within http2.connect be called in exactly the same way, and even when the args are the same as in my version it still doesn't work.

@murgatroid99
Copy link
Member

Just to verify in the other direction, if you modify your version of the code to pass the same arguments that the old code passes, your version still works?

@mrfelton
Copy link
Contributor Author

mrfelton commented Apr 18, 2020

Yes, it does.

i.e. I can change to this and it still works:

const cts = tls.connect(443, getDefaultAuthority(realTarget), initializeTLSOptions({ ...connectionOptions, socket, host: '127.0.0.1', port: 9065}, getDefaultAuthority(realTarget)), () => {
  resolve({ socket: cts, realTarget });
});

Which gives me call opts that look like those from the original code:

[
  443,
  'zapn34qfeedw2l5y26p3hnnkusqnbhxcxw64lq5cojmvq45yw4bc3sqd.onion',
  {
    secureContext: SecureContext { context: SecureContext {} },
    socket: Socket {
      connecting: false,
      _hadError: false,
      _parent: null,
      _host: null,
      _readableState: [ReadableState],
      readable: true,
      _events: [Object: null prototype] {},
      _eventsCount: 0,
      _maxListeners: undefined,
      _writableState: [WritableState],
      writable: true,
      allowHalfOpen: false,
      _sockname: null,
      _pendingData: null,
      _pendingEncoding: '',
      server: null,
      _server: null,
      parser: null,
      _httpMessage: null,
      [Symbol(asyncId)]: 134,
      [Symbol(kHandle)]: [TCP],
      [Symbol(lastWriteQueueSize)]: 0,
      [Symbol(timeout)]: null,
      [Symbol(kBuffer)]: null,
      [Symbol(kBufferCb)]: null,
      [Symbol(kBufferGen)]: null,
      [Symbol(kBytesRead)]: 0,
      [Symbol(kBytesWritten)]: 0
    },
    host: '127.0.0.1',
    port: 9065,
    settings: {},
    Http1IncomingMessage: [Function: IncomingMessage],
    Http1ServerResponse: [Function: ServerResponse],
    Http2ServerRequest: [Function: Http2ServerRequest],
    Http2ServerResponse: [Function: Http2ServerResponse],
    ALPNProtocols: [ 'h2' ],
    servername: 'zapn34qfeedw2l5y26p3hnnkusqnbhxcxw64lq5cojmvq45yw4bc3sqd.onion'
  },
  [Function]
]

There is a different though, which is the callback function as the 4th arg.

It seems to me that in my version when calling tls.connect from within request.once('connect' in the proxy module it waits for the socket to be established before resolving it from getProxiedConnection. Which in turn means that the socket that gets passed to http2.connect is already fully established and tls enabled.

In http2 core they don't call tls.connect with a callback.

They will however call a listener that you can pass to http2.connect as a third argument, which to me looks like it would call once the socket has been upgraded for tls. See here:

https://github.com/nodejs/node/blob/ef2df6986b6a3c34c6a0a97c120d5f8d05c4a1ce/lib/internal/http2/core.js#L2952-L2953

However we don't pass in a `listener third arg.

I may be barking up the wrong tree, but it seems that our socket is just not ready to use with tls before we start trying to use it.

@murgatroid99
Copy link
Member

It should be pretty easy to check if passing a callback argument to http2.connect is the issue. The session.once('connect') callback from a few lines down could be passed directly as the third argument to http2.connect.

In the other direction, what if in this PR you have the proxy code return the TLS socket immediately, instead of passing a callback?

@murgatroid99
Copy link
Member

I think I may have a smoking gun: the Http2Session constructor waits for a TLSSocket's secureConnect event if socket.connecting is true, but continues immediately otherwise. (https://github.com/nodejs/node/blob/ef2df6986b6a3c34c6a0a97c120d5f8d05c4a1ce/lib/internal/http2/core.js#L1084-L1090). However, if the TLSSocket constructor is passed a socket, then it only sets its own connecting value to true if the underlying socket's connecting is also true, but it emits connect, not secureConnect at that time. (https://github.com/nodejs/node/blob/master/lib/_tls_wrap.js#L791-L800). This looks like a mismatch of expectations: Http2Session is expecting that connecting means "has not yet emitted the secureConnect event", but in this situation TLSSocket is using it to mean "has not yet emitted the connect event".

If this is true, passing a callback to http.connect will not fix the problem, and returning the result of tls.connect without waiting for it to finish will make the code in this PR behave like the broken version.

@murgatroid99
Copy link
Member

See nodejs/node#32922 for a demonstration of the problem I described independent of both grpc and HTTP CONNECT.

@mrfelton
Copy link
Contributor Author

It should be pretty easy to check if passing a callback argument to http2.connect is the issue. The session.once('connect') callback from a few lines down could be passed directly as the third argument to http2.connect.

Does't work this way.

In the other direction, what if in this PR you have the proxy code return the TLS socket immediately, instead of passing a callback?

Also does't work this way.

See nodejs/node#32922 for a demonstration of the problem I described independent of both grpc and HTTP CONNECT.

Wow ok, so this really does look like a bug in node itself. With that in mind, how do you feel about the workaround proposed in this PR?

Copy link
Member

@murgatroid99 murgatroid99 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I understand what the problem is, I'm OK with adding the workaround, with a few changes.

/* 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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section of the change now doesn't functionally change anything, so I'd rather not change it at all.

packages/grpc-js/src/subchannel.ts Outdated Show resolved Hide resolved
packages/grpc-js/src/subchannel.ts Show resolved Hide resolved
packages/grpc-js/src/http_proxy.ts Outdated Show resolved Hide resolved
packages/grpc-js/src/http_proxy.ts Outdated Show resolved Hide resolved
packages/grpc-js/src/http_proxy.ts Outdated Show resolved Hide resolved
@mrfelton
Copy link
Contributor Author

Thanks. Have made those updates.

Copy link
Member

@murgatroid99 murgatroid99 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you so much for working through this with me.

@mrfelton
Copy link
Contributor Author

Thanks for your help - solving this and making it possible to do grpc over tor is a big deal. 👍

@murgatroid99 murgatroid99 merged commit c5424a5 into grpc:master Apr 20, 2020
@mrfelton
Copy link
Contributor Author

Unfortunately this no longer works after merging :(

@mrfelton
Copy link
Contributor Author

mrfelton commented Apr 21, 2020

I think it's this PR that conflicts #1364

@murgatroid99
Copy link
Member

I think I fixed the conflict in #1375.

@murgatroid99
Copy link
Member

Version 1.0.1 has that fix.

@mrfelton
Copy link
Contributor Author

progress, but still not working unfortunately. Now getting Received RST_STREAM with code 2 error and the tls session again doesn't look to being getting established correctly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants