Skip to content

Commit

Permalink
feat: HTTP CONNECT client (review)
Browse files Browse the repository at this point in the history
  • Loading branch information
nikolaikabanenkov committed Jan 12, 2025
1 parent 7b36d64 commit b9e4f2d
Show file tree
Hide file tree
Showing 4 changed files with 45 additions and 50 deletions.
15 changes: 8 additions & 7 deletions x/httpconnect/connect_client.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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")
}
Expand All @@ -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()
}
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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,
Expand Down
46 changes: 11 additions & 35 deletions x/httpconnect/connect_client_test.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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"
Expand All @@ -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}}),
)
Expand Down Expand Up @@ -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")
Expand Down
16 changes: 16 additions & 0 deletions x/httpconnect/doc.go
Original file line number Diff line number Diff line change
@@ -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
18 changes: 10 additions & 8 deletions x/httpconnect/pipe_conn.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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())
}

0 comments on commit b9e4f2d

Please sign in to comment.