From 8768cb91faab2331f611f2870111bd62ff0fb8e1 Mon Sep 17 00:00:00 2001 From: Nikolai Kabanenkov Date: Sun, 12 Jan 2025 12:37:13 +0500 Subject: [PATCH] feat: HTTP CONNECT client (review) --- x/httpconnect/connect_client.go | 15 ++++----- x/httpconnect/connect_client_test.go | 46 +++++++--------------------- x/httpconnect/doc.go | 16 ++++++++++ x/httpconnect/pipe_conn.go | 18 ++++++----- 4 files changed, 45 insertions(+), 50 deletions(-) create mode 100644 x/httpconnect/doc.go diff --git a/x/httpconnect/connect_client.go b/x/httpconnect/connect_client.go index 017ee17c..03990bc2 100644 --- a/x/httpconnect/connect_client.go +++ b/x/httpconnect/connect_client.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Outline Authors +// Copyright 2025 The Outline Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -35,9 +35,9 @@ type connectClient struct { var _ transport.StreamDialer = (*connectClient)(nil) -type ConnectClientOption func(c *connectClient) +type ClientOption func(c *connectClient) -func NewConnectClient(dialer transport.StreamDialer, proxyAddr string, opts ...ConnectClientOption) (transport.StreamDialer, error) { +func NewConnectClient(dialer transport.StreamDialer, proxyAddr string, opts ...ClientOption) (transport.StreamDialer, error) { if dialer == nil { return nil, errors.New("dialer must not be nil") } @@ -60,9 +60,9 @@ func NewConnectClient(dialer transport.StreamDialer, proxyAddr string, opts ...C } // WithHeaders appends the given [headers] to the CONNECT request -func WithHeaders(headers http.Header) ConnectClientOption { +func WithHeaders(headers http.Header) ClientOption { return func(c *connectClient) { - c.headers = headers + c.headers = headers.Clone() } } @@ -90,7 +90,7 @@ func (cc *connectClient) doConnect(ctx context.Context, remoteAddr string, conn pr, pw := io.Pipe() - req, err := http.NewRequestWithContext(ctx, http.MethodConnect, "http://"+remoteAddr, pr) + req, err := http.NewRequestWithContext(ctx, http.MethodConnect, "http://"+remoteAddr, pr) // TODO: HTTPS support if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -113,10 +113,11 @@ func (cc *connectClient) doConnect(ctx context.Context, remoteAddr string, conn return nil, fmt.Errorf("do: %w", err) } if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } - return &PipeConn{ + return &pipeConn{ reader: resp.Body, writer: pw, StreamConn: conn, diff --git a/x/httpconnect/connect_client_test.go b/x/httpconnect/connect_client_test.go index d7fe2961..0c28e03b 100644 --- a/x/httpconnect/connect_client_test.go +++ b/x/httpconnect/connect_client_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Outline Authors +// Copyright 2025 The Outline Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -19,8 +19,8 @@ import ( "context" "encoding/base64" "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/Jigsaw-Code/outline-sdk/x/httpproxy" "github.com/stretchr/testify/require" - "io" "net" "net/http" "net/http/httptest" @@ -44,41 +44,19 @@ func TestConnectClientOk(t *testing.T) { targetURL, err := url.Parse(targetSrv.URL) require.NoError(t, err) - proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodConnect, r.Method, "Method") - require.Equal(t, targetURL.Host, r.Host, "Host") - require.Equal(t, []string{"Basic " + creds}, r.Header["Proxy-Authorization"], "Proxy-Authorization") - - conn, err := net.Dial("tcp", targetURL.Host) - require.NoError(t, err, "Dial") - - w.WriteHeader(http.StatusOK) - _, err = w.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")) - require.NoError(t, err, "Write") - - rc := http.NewResponseController(w) - err = rc.Flush() - require.NoError(t, err, "Flush") - - clientConn, _, err := rc.Hijack() - require.NoError(t, err, "Hijack") - - go func() { - _, _ = io.Copy(conn, clientConn) - }() - _, _ = io.Copy(clientConn, conn) + tcpDialer := &transport.TCPDialer{Dialer: net.Dialer{}} + connectHandler := httpproxy.NewConnectHandler(tcpDialer) + proxySrv := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + require.Equal(t, "Basic "+creds, request.Header.Get("Proxy-Authorization")) + connectHandler.ServeHTTP(writer, request) })) defer proxySrv.Close() proxyURL, err := url.Parse(proxySrv.URL) require.NoError(t, err, "Parse") - dialer := &transport.TCPDialer{ - Dialer: net.Dialer{}, - } - connClient, err := NewConnectClient( - dialer, + tcpDialer, proxyURL.Host, WithHeaders(http.Header{"Proxy-Authorization": []string{"Basic " + creds}}), ) @@ -118,12 +96,10 @@ func TestConnectClientFail(t *testing.T) { proxyURL, err := url.Parse(proxySrv.URL) require.NoError(t, err, "Parse") - dialer := &transport.TCPDialer{ - Dialer: net.Dialer{}, - } - connClient, err := NewConnectClient( - dialer, + &transport.TCPDialer{ + Dialer: net.Dialer{}, + }, proxyURL.Host, ) require.NoError(t, err, "NewConnectClient") diff --git a/x/httpconnect/doc.go b/x/httpconnect/doc.go new file mode 100644 index 00000000..54418797 --- /dev/null +++ b/x/httpconnect/doc.go @@ -0,0 +1,16 @@ +// Copyright 2025 The Outline 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 +// +// https://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. + +// Package httpconnect contains an HTTP CONNECT client implementation. +package httpconnect diff --git a/x/httpconnect/pipe_conn.go b/x/httpconnect/pipe_conn.go index 82ed516b..50f174a3 100644 --- a/x/httpconnect/pipe_conn.go +++ b/x/httpconnect/pipe_conn.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Outline Authors +// Copyright 2025 The Outline Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -20,29 +20,31 @@ import ( "io" ) -// PipeConn is a [transport.StreamConn] that overrides [Read], [Write] (and corresponding [Close]) functions with the given [reader] and [writer] -type PipeConn struct { +var _ transport.StreamConn = (*pipeConn)(nil) + +// pipeConn is a [transport.StreamConn] that overrides [Read], [Write] (and corresponding [Close]) functions with the given [reader] and [writer] +type pipeConn struct { reader io.ReadCloser writer io.WriteCloser transport.StreamConn } -func (p *PipeConn) Read(b []byte) (n int, err error) { +func (p *pipeConn) Read(b []byte) (n int, err error) { return p.reader.Read(b) } -func (p *PipeConn) Write(b []byte) (n int, err error) { +func (p *pipeConn) Write(b []byte) (n int, err error) { return p.writer.Write(b) } -func (p *PipeConn) CloseRead() error { +func (p *pipeConn) CloseRead() error { return errors.Join(p.reader.Close(), p.StreamConn.CloseRead()) } -func (p *PipeConn) CloseWrite() error { +func (p *pipeConn) CloseWrite() error { return errors.Join(p.writer.Close(), p.StreamConn.CloseWrite()) } -func (p *PipeConn) Close() error { +func (p *pipeConn) Close() error { return errors.Join(p.reader.Close(), p.writer.Close(), p.StreamConn.Close()) }