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

feat: HTTP/1.1 CONNECT client #350

Merged
merged 5 commits into from
Jan 13, 2025

Conversation

nikolaikabanenkov
Copy link
Contributor

Hi,

The merge request adds an HTTP/1.1 CONNECT client, as described in #319

As for HTTP/2 support, I'm getting to know the HTTP/2 implementation in Go and will share an implementation later.

Sincerely,
Nikolai

Copy link
Contributor

@fortuna fortuna 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 for the contribution. I have a few comments.

Copy link
Contributor

Choose a reason for hiding this comment

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

Could you please move the code to a new package x/httpconnect?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, done!

return conn, nil
}

func (c *connectClient) sendConnectRequest(ctx context.Context, remoteAddr string, conn transport.StreamConn) error {
Copy link
Contributor

Choose a reason for hiding this comment

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

I was hoping to use the http.Client to avoid replicating logic, provide extra functionality, and keep it less tied to HTTP/1.1.

Does https://pkg.go.dev/net/http#Client.Do not work with a CONNECT method?

I think you may need https://pkg.go.dev/net/http#NewResponseController to enable full duplex and hijack the connection.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

A great insight!

http.Client does work with a CONNECT request.

A ResponseController can take over a server-side connection, but not a client-side one. However, I have found a way to achieve this with an http.Client using an io.Pipe.


// connectClient is a [transport.StreamDialer] implementation that sends a CONNECT request
type connectClient struct {
endpoint transport.StreamEndpoint
Copy link
Contributor

Choose a reason for hiding this comment

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

You need to know the proxy address to pass to connect.
The connection RemoteAddress has the connected IP, it loses the domain information.
I suggest you pass the address and a dialer instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

A good idea, done

endpoint transport.StreamEndpoint

// proxyAuth is the Proxy-Authorization header value. If empty, the header is not sent
proxyAuth string
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps have a http.Header object here instead that gets merged into the request.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right, it is better to merge. Done

connClient, err := NewConnectClient(endpoint, WithProxyAuthorization("Basic "+creds))
require.NoError(t, err, "NewConnectClient")

streamConn, err := connClient.DialStream(context.Background(), host)
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you write "Request" to the connection and expect the response to be "Response"? That will exercise the exchange as well, which has been an issue before.

Copy link
Contributor Author

@nikolaikabanenkov nikolaikabanenkov Jan 6, 2025

Choose a reason for hiding this comment

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

I am not sure what you are referring to. Could you, please, provide a small example?
If what we read is not a valid response, we should perform an early return with a non-nil error, should we not?

Upd. I have added a testcase for that unhappy path.

Copy link
Contributor

@fortuna fortuna left a comment

Choose a reason for hiding this comment

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

Just a couple of small things

// WithHeaders appends the given [headers] to the CONNECT request
func WithHeaders(headers http.Header) ConnectClientOption {
return func(c *connectClient) {
c.headers = headers
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you copy the headers into the internal one instead? That is safer and lets the caller reuse the input headers

Copy link
Contributor Author

Choose a reason for hiding this comment

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

A good point, done


pr, pw := io.Pipe()

req, err := http.NewRequestWithContext(ctx, http.MethodConnect, "http://"+remoteAddr, pr)
Copy link
Contributor

Choose a reason for hiding this comment

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

We may want to specify whether we want to use HTTP or HTTPS. Typically TLS has to be handled by the HTTP Client. But for now I think it's ok.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok. I have added a TODO on that line

)

// PipeConn is a [transport.StreamConn] that overrides [Read], [Write] (and corresponding [Close]) functions with the given [reader] and [writer]
type PipeConn struct {
Copy link
Contributor

Choose a reason for hiding this comment

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

Keep this private for now

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, done

targetURL, err := url.Parse(targetSrv.URL)
require.NoError(t, err)

proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Copy link
Contributor

Choose a reason for hiding this comment

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

You could use https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/x@v0.0.0-20250110201030-80a3f247a159/httpproxy#NewConnectHandler instead of duplicating the logic. Though we don't have support for authorization there.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the suggestion! I have replaced the duplicating logic with a ConnectHandler

Copy link
Contributor

@fortuna fortuna left a comment

Choose a reason for hiding this comment

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

Looks good. Thank you for the contribution!

@fortuna fortuna enabled auto-merge (squash) January 13, 2025 16:19
@fortuna fortuna merged commit efa8083 into Jigsaw-Code:main Jan 13, 2025
4 checks passed
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.

2 participants