From c2a93140c4d2a7df5666c7e436d8f1cb337a579d Mon Sep 17 00:00:00 2001 From: Sanskar Jaiswal Date: Thu, 13 Apr 2023 12:49:53 +0530 Subject: plumbing/transport: add ProxyOptions to specify proxy details Signed-off-by: Sanskar Jaiswal --- plumbing/transport/common.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) (limited to 'plumbing') diff --git a/plumbing/transport/common.go b/plumbing/transport/common.go index a2a78f0..89bd3d5 100644 --- a/plumbing/transport/common.go +++ b/plumbing/transport/common.go @@ -116,6 +116,37 @@ type Endpoint struct { InsecureSkipTLS bool // CaBundle specify additional ca bundle with system cert pool CaBundle []byte + // Proxy provides info required for connecting to a proxy. + Proxy ProxyOptions +} + +type ProxyOptions struct { + URL string + Username string + Password string +} + +func (o *ProxyOptions) Validate() error { + if o.URL != "" { + _, err := url.Parse(o.URL) + return err + } + return nil +} + +func (o *ProxyOptions) FullURL() (*url.URL, error) { + proxyURL, err := url.Parse(o.URL) + if err != nil { + return nil, err + } + if o.Username != "" { + if o.Password != "" { + proxyURL.User = url.UserPassword(o.Username, o.Password) + } else { + proxyURL.User = url.User(o.Username) + } + } + return proxyURL, nil } var defaultPorts = map[string]int{ -- cgit From 223727feb195642234a600040b12a2d3597d0989 Mon Sep 17 00:00:00 2001 From: Sanskar Jaiswal Date: Thu, 13 Apr 2023 16:29:13 +0530 Subject: plumbing: transport/ssh, add support for custom proxy URLs Signed-off-by: Sanskar Jaiswal --- plumbing/transport/ssh/common.go | 30 +++++- plumbing/transport/ssh/internal/test/proxy_test.go | 113 +++++++++++++++++++++ plumbing/transport/ssh/internal/test/test_utils.go | 83 +++++++++++++++ plumbing/transport/ssh/proxy_test.go | 72 +++++++++++-- plumbing/transport/ssh/upload_pack_test.go | 3 +- 5 files changed, 287 insertions(+), 14 deletions(-) create mode 100644 plumbing/transport/ssh/internal/test/proxy_test.go create mode 100644 plumbing/transport/ssh/internal/test/test_utils.go (limited to 'plumbing') diff --git a/plumbing/transport/ssh/common.go b/plumbing/transport/ssh/common.go index e06958a..6617d9b 100644 --- a/plumbing/transport/ssh/common.go +++ b/plumbing/transport/ssh/common.go @@ -4,6 +4,7 @@ package ssh import ( "context" "fmt" + "net" "reflect" "strconv" "strings" @@ -139,7 +140,7 @@ func (c *command) connect() error { overrideConfig(c.config, config) - c.client, err = dial("tcp", hostWithPort, config) + c.client, err = dial("tcp", hostWithPort, c.endpoint.Proxy, config) if err != nil { return err } @@ -154,7 +155,7 @@ func (c *command) connect() error { return nil } -func dial(network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { +func dial(network, addr string, proxyOpts transport.ProxyOptions, config *ssh.ClientConfig) (*ssh.Client, error) { var ( ctx = context.Background() cancel context.CancelFunc @@ -166,10 +167,33 @@ func dial(network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { } defer cancel() - conn, err := proxy.Dial(ctx, network, addr) + var conn net.Conn + var err error + + if proxyOpts.URL != "" { + proxyUrl, err := proxyOpts.FullURL() + if err != nil { + return nil, err + } + dialer, err := proxy.FromURL(proxyUrl, proxy.Direct) + if err != nil { + return nil, err + } + + // Try to use a ContextDialer, but fall back to a Dialer if that goes south. + ctxDialer, ok := dialer.(proxy.ContextDialer) + if !ok { + return nil, fmt.Errorf("expected ssh proxy dialer to be of type %s; got %s", + reflect.TypeOf(ctxDialer), reflect.TypeOf(dialer)) + } + conn, err = ctxDialer.DialContext(ctx, "tcp", addr) + } else { + conn, err = proxy.Dial(ctx, network, addr) + } if err != nil { return nil, err } + c, chans, reqs, err := ssh.NewClientConn(conn, addr, config) if err != nil { return nil, err diff --git a/plumbing/transport/ssh/internal/test/proxy_test.go b/plumbing/transport/ssh/internal/test/proxy_test.go new file mode 100644 index 0000000..8baac2b --- /dev/null +++ b/plumbing/transport/ssh/internal/test/proxy_test.go @@ -0,0 +1,113 @@ +package test + +import ( + "context" + "fmt" + "io/ioutil" + "log" + "net" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/armon/go-socks5" + "github.com/gliderlabs/ssh" + "github.com/go-git/go-git/v5/plumbing/transport" + ggssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" + + fixtures "github.com/go-git/go-git-fixtures/v4" + stdssh "golang.org/x/crypto/ssh" + . "gopkg.in/check.v1" +) + +func Test(t *testing.T) { TestingT(t) } + +type ProxyEnvSuite struct { + fixtures.Suite + port int + base string +} + +var _ = Suite(&ProxyEnvSuite{}) + +var socksProxiedRequests int32 + +// This test tests proxy support via an env var, i.e. `ALL_PROXY`. +// Its located in a separate package because golang caches the value +// of proxy env vars leading to misleading/unexpected test results. +func (s *ProxyEnvSuite) TestCommand(c *C) { + socksListener, err := net.Listen("tcp", "localhost:0") + c.Assert(err, IsNil) + + socksServer, err := socks5.New(&socks5.Config{ + Rules: TestProxyRule{}, + }) + c.Assert(err, IsNil) + go func() { + socksServer.Serve(socksListener) + }() + socksProxyAddr := fmt.Sprintf("socks5://localhost:%d", socksListener.Addr().(*net.TCPAddr).Port) + os.Setenv("ALL_PROXY", socksProxyAddr) + defer os.Unsetenv("ALL_PROXY") + + sshListener, err := net.Listen("tcp", "localhost:0") + c.Assert(err, IsNil) + sshServer := &ssh.Server{Handler: HandlerSSH} + go func() { + log.Fatal(sshServer.Serve(sshListener)) + }() + + s.port = sshListener.Addr().(*net.TCPAddr).Port + s.base, err = ioutil.TempDir(os.TempDir(), fmt.Sprintf("go-git-ssh-%d", s.port)) + c.Assert(err, IsNil) + + ggssh.DefaultAuthBuilder = func(user string) (ggssh.AuthMethod, error) { + return &ggssh.Password{User: user}, nil + } + + ep := s.prepareRepository(c, fixtures.Basic().One(), "basic.git") + c.Assert(err, IsNil) + + client := ggssh.NewClient(&stdssh.ClientConfig{ + HostKeyCallback: stdssh.InsecureIgnoreHostKey(), + }) + r, err := client.NewUploadPackSession(ep, nil) + c.Assert(err, IsNil) + defer func() { c.Assert(r.Close(), IsNil) }() + + info, err := r.AdvertisedReferences() + c.Assert(err, IsNil) + c.Assert(info, NotNil) + proxyUsed := atomic.LoadInt32(&socksProxiedRequests) > 0 + c.Assert(proxyUsed, Equals, true) +} + +func (s *ProxyEnvSuite) prepareRepository(c *C, f *fixtures.Fixture, name string) *transport.Endpoint { + fs := f.DotGit() + + err := fixtures.EnsureIsBare(fs) + c.Assert(err, IsNil) + + path := filepath.Join(s.base, name) + err = os.Rename(fs.Root(), path) + c.Assert(err, IsNil) + + return s.newEndpoint(c, name) +} + +func (s *ProxyEnvSuite) newEndpoint(c *C, name string) *transport.Endpoint { + ep, err := transport.NewEndpoint(fmt.Sprintf( + "ssh://git@localhost:%d/%s/%s", s.port, filepath.ToSlash(s.base), name, + )) + + c.Assert(err, IsNil) + return ep +} + +type TestProxyRule struct{} + +func (dr TestProxyRule) Allow(ctx context.Context, req *socks5.Request) (context.Context, bool) { + atomic.AddInt32(&socksProxiedRequests, 1) + return ctx, true +} diff --git a/plumbing/transport/ssh/internal/test/test_utils.go b/plumbing/transport/ssh/internal/test/test_utils.go new file mode 100644 index 0000000..c3797b1 --- /dev/null +++ b/plumbing/transport/ssh/internal/test/test_utils.go @@ -0,0 +1,83 @@ +package test + +import ( + "fmt" + "io" + "os/exec" + "runtime" + "strings" + "sync" + + "github.com/gliderlabs/ssh" +) + +func HandlerSSH(s ssh.Session) { + cmd, stdin, stderr, stdout, err := buildCommand(s.Command()) + if err != nil { + fmt.Println(err) + return + } + + if err := cmd.Start(); err != nil { + fmt.Println(err) + return + } + + go func() { + defer stdin.Close() + io.Copy(stdin, s) + }() + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + io.Copy(s.Stderr(), stderr) + }() + + go func() { + defer wg.Done() + io.Copy(s, stdout) + }() + + wg.Wait() + + if err := cmd.Wait(); err != nil { + return + } + +} + +func buildCommand(c []string) (cmd *exec.Cmd, stdin io.WriteCloser, stderr, stdout io.ReadCloser, err error) { + if len(c) != 2 { + err = fmt.Errorf("invalid command") + return + } + + // fix for Windows environments + var path string + if runtime.GOOS == "windows" { + path = strings.Replace(c[1], "/C:/", "C:/", 1) + } else { + path = c[1] + } + + cmd = exec.Command(c[0], path) + stdout, err = cmd.StdoutPipe() + if err != nil { + return + } + + stdin, err = cmd.StdinPipe() + if err != nil { + return + } + + stderr, err = cmd.StderrPipe() + if err != nil { + return + } + + return +} diff --git a/plumbing/transport/ssh/proxy_test.go b/plumbing/transport/ssh/proxy_test.go index 3caf1ff..2fab851 100644 --- a/plumbing/transport/ssh/proxy_test.go +++ b/plumbing/transport/ssh/proxy_test.go @@ -1,36 +1,88 @@ package ssh import ( + "context" "fmt" + "io/ioutil" "log" "net" "os" + "sync/atomic" "github.com/armon/go-socks5" + "github.com/gliderlabs/ssh" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/ssh/internal/test" + + fixtures "github.com/go-git/go-git-fixtures/v4" + stdssh "golang.org/x/crypto/ssh" . "gopkg.in/check.v1" ) type ProxySuite struct { - UploadPackSuite + u UploadPackSuite + fixtures.Suite } var _ = Suite(&ProxySuite{}) -func (s *ProxySuite) SetUpSuite(c *C) { - s.UploadPackSuite.SetUpSuite(c) +var socksProxiedRequests int32 - l, err := net.Listen("tcp", "localhost:0") +func (s *ProxySuite) TestCommand(c *C) { + socksListener, err := net.Listen("tcp", "localhost:0") c.Assert(err, IsNil) - server, err := socks5.New(&socks5.Config{}) + socksServer, err := socks5.New(&socks5.Config{ + AuthMethods: []socks5.Authenticator{socks5.UserPassAuthenticator{ + Credentials: socks5.StaticCredentials{ + "user": "pass", + }, + }}, + Rules: TestProxyRule{}, + }) c.Assert(err, IsNil) + go func() { + socksServer.Serve(socksListener) + }() + socksProxyAddr := fmt.Sprintf("socks5://localhost:%d", socksListener.Addr().(*net.TCPAddr).Port) - port := l.Addr().(*net.TCPAddr).Port - - err = os.Setenv("ALL_PROXY", fmt.Sprintf("socks5://localhost:%d", port)) + sshListener, err := net.Listen("tcp", "localhost:0") c.Assert(err, IsNil) - + sshServer := &ssh.Server{Handler: test.HandlerSSH} go func() { - log.Fatal(server.Serve(l)) + log.Fatal(sshServer.Serve(sshListener)) }() + + s.u.port = sshListener.Addr().(*net.TCPAddr).Port + s.u.base, err = ioutil.TempDir(os.TempDir(), fmt.Sprintf("go-git-ssh-%d", s.u.port)) + c.Assert(err, IsNil) + + DefaultAuthBuilder = func(user string) (AuthMethod, error) { + return &Password{User: user}, nil + } + + ep := s.u.prepareRepository(c, fixtures.Basic().One(), "basic.git") + c.Assert(err, IsNil) + ep.Proxy = transport.ProxyOptions{ + URL: socksProxyAddr, + Username: "user", + Password: "pass", + } + + runner := runner{ + config: &stdssh.ClientConfig{ + HostKeyCallback: stdssh.InsecureIgnoreHostKey(), + }, + } + _, err = runner.Command(transport.UploadPackServiceName, ep, nil) + c.Assert(err, IsNil) + proxyUsed := atomic.LoadInt32(&socksProxiedRequests) > 0 + c.Assert(proxyUsed, Equals, true) +} + +type TestProxyRule struct{} + +func (dr TestProxyRule) Allow(ctx context.Context, req *socks5.Request) (context.Context, bool) { + atomic.AddInt32(&socksProxiedRequests, 1) + return ctx, true } diff --git a/plumbing/transport/ssh/upload_pack_test.go b/plumbing/transport/ssh/upload_pack_test.go index f172fee..fafff48 100644 --- a/plumbing/transport/ssh/upload_pack_test.go +++ b/plumbing/transport/ssh/upload_pack_test.go @@ -14,6 +14,7 @@ import ( "sync" "github.com/go-git/go-git/v5/plumbing/transport" + testutils "github.com/go-git/go-git/v5/plumbing/transport/ssh/internal/test" "github.com/go-git/go-git/v5/plumbing/transport/test" "github.com/gliderlabs/ssh" @@ -57,7 +58,7 @@ func (s *UploadPackSuite) SetUpSuite(c *C) { s.UploadPackSuite.EmptyEndpoint = s.prepareRepository(c, fixtures.ByTag("empty").One(), "empty.git") s.UploadPackSuite.NonExistentEndpoint = s.newEndpoint(c, "non-existent.git") - server := &ssh.Server{Handler: handlerSSH} + server := &ssh.Server{Handler: testutils.HandlerSSH} for _, opt := range s.opts { opt(server) } -- cgit From 399b1ec2d598b7950816727b8d92e8580553372c Mon Sep 17 00:00:00 2001 From: Sanskar Jaiswal Date: Tue, 18 Apr 2023 16:31:58 +0530 Subject: plumbing: transport/http, refactor transport to cache underlying transport objects Refactor the in-built http transport to cache the underlying http transport objects mapped to its specific options for each Git transport object. This lets us reuse the transport for a specific set of configurations as recommended. (ref: https://pkg.go.dev/net/http#Transport) If there are no transport specific options provided, the default transport is used. Signed-off-by: Sanskar Jaiswal --- plumbing/transport/client/client.go | 32 ------- plumbing/transport/http/common.go | 147 +++++++++++++++++++++++++++++--- plumbing/transport/http/common_test.go | 54 ++++++++++++ plumbing/transport/http/receive_pack.go | 2 +- plumbing/transport/http/transport.go | 38 +++++++++ plumbing/transport/http/upload_pack.go | 2 +- 6 files changed, 230 insertions(+), 45 deletions(-) create mode 100644 plumbing/transport/http/transport.go (limited to 'plumbing') diff --git a/plumbing/transport/client/client.go b/plumbing/transport/client/client.go index 20c3d05..1948c23 100644 --- a/plumbing/transport/client/client.go +++ b/plumbing/transport/client/client.go @@ -3,10 +3,7 @@ package client import ( - "crypto/tls" - "crypto/x509" "fmt" - gohttp "net/http" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/file" @@ -24,14 +21,6 @@ var Protocols = map[string]transport.Transport{ "file": file.DefaultClient, } -var insecureClient = http.NewClient(&gohttp.Client{ - Transport: &gohttp.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - }, -}) - // InstallProtocol adds or modifies an existing protocol. func InstallProtocol(scheme string, c transport.Transport) { if c == nil { @@ -50,27 +39,6 @@ func NewClient(endpoint *transport.Endpoint) (transport.Transport, error) { } func getTransport(endpoint *transport.Endpoint) (transport.Transport, error) { - if endpoint.Protocol == "https" { - if endpoint.InsecureSkipTLS { - return insecureClient, nil - } - - if len(endpoint.CaBundle) != 0 { - rootCAs, _ := x509.SystemCertPool() - if rootCAs == nil { - rootCAs = x509.NewCertPool() - } - rootCAs.AppendCertsFromPEM(endpoint.CaBundle) - return http.NewClient(&gohttp.Client{ - Transport: &gohttp.Transport{ - TLSClientConfig: &tls.Config{ - RootCAs: rootCAs, - }, - }, - }), nil - } - } - f, ok := Protocols[endpoint.Protocol] if !ok { return nil, fmt.Errorf("unsupported scheme %q", endpoint.Protocol) diff --git a/plumbing/transport/http/common.go b/plumbing/transport/http/common.go index d57c0fe..5300341 100644 --- a/plumbing/transport/http/common.go +++ b/plumbing/transport/http/common.go @@ -4,16 +4,21 @@ package http import ( "bytes" "context" + "crypto/tls" + "crypto/x509" "fmt" "net" "net/http" + "reflect" "strconv" "strings" + "sync" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/protocol/packp" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/utils/ioutil" + "github.com/golang/groupcache/lru" ) // it requires a bytes.Buffer, because we need to know the length @@ -74,40 +79,83 @@ func advertisedReferences(ctx context.Context, s *session, serviceName string) ( } type client struct { - c *http.Client + c *http.Client + transports *lru.Cache + m sync.RWMutex } -// DefaultClient is the default HTTP client, which uses `http.DefaultClient`. -var DefaultClient = NewClient(nil) +// ClientOptions holds user configurable options for the client. +type ClientOptions struct { + // CacheMaxEntries is the max no. of entries that the transport objects + // cache will hold at any given point of time. It must be a positive integer. + // Calling `client.addTransport()` after the cache has reached the specified + // size, will result in the least recently used transport getting deleted + // before the provided transport is added to the cache. + CacheMaxEntries int +} + +var ( + // defaultTransportCacheSize is the default capacity of the transport objects cache. + // Its value is 0 because transport caching is turned off by default and is an + // opt-in feature. + defaultTransportCacheSize = 0 + + // DefaultClient is the default HTTP client, which uses a net/http client configured + // with http.DefaultTransport. + DefaultClient = NewClient(nil) +) // NewClient creates a new client with a custom net/http client. // See `InstallProtocol` to install and override default http client. -// Unless a properly initialized client is given, it will fall back into -// `http.DefaultClient`. +// If the net/http client is nil or empty, it will use a net/http client configured +// with http.DefaultTransport. // // Note that for HTTP client cannot distinguish between private repositories and // unexistent repositories on GitHub. So it returns `ErrAuthorizationRequired` // for both. func NewClient(c *http.Client) transport.Transport { if c == nil { - return &client{http.DefaultClient} + c = &http.Client{ + Transport: http.DefaultTransport, + } } + return NewClientWithOptions(c, &ClientOptions{ + CacheMaxEntries: defaultTransportCacheSize, + }) +} - return &client{ +// NewClientWithOptions returns a new client configured with the provided net/http client +// and other custom options specific to the client. +// If the net/http client is nil or empty, it will use a net/http client configured +// with http.DefaultTransport. +func NewClientWithOptions(c *http.Client, opts *ClientOptions) transport.Transport { + if c == nil { + c = &http.Client{ + Transport: http.DefaultTransport, + } + } + cl := &client{ c: c, } + + if opts != nil { + if opts.CacheMaxEntries > 0 { + cl.transports = lru.New(opts.CacheMaxEntries) + } + } + return cl } func (c *client) NewUploadPackSession(ep *transport.Endpoint, auth transport.AuthMethod) ( transport.UploadPackSession, error) { - return newUploadPackSession(c.c, ep, auth) + return newUploadPackSession(c, ep, auth) } func (c *client) NewReceivePackSession(ep *transport.Endpoint, auth transport.AuthMethod) ( transport.ReceivePackSession, error) { - return newReceivePackSession(c.c, ep, auth) + return newReceivePackSession(c, ep, auth) } type session struct { @@ -117,10 +165,87 @@ type session struct { advRefs *packp.AdvRefs } -func newSession(c *http.Client, ep *transport.Endpoint, auth transport.AuthMethod) (*session, error) { +func transportWithInsecureTLS(transport *http.Transport) { + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} + } + transport.TLSClientConfig.InsecureSkipVerify = true +} + +func transportWithCABundle(transport *http.Transport, caBundle []byte) error { + rootCAs, err := x509.SystemCertPool() + if err != nil { + return err + } + if rootCAs == nil { + rootCAs = x509.NewCertPool() + } + rootCAs.AppendCertsFromPEM(caBundle) + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} + } + transport.TLSClientConfig.RootCAs = rootCAs + return nil +} + +func configureTransport(transport *http.Transport, ep *transport.Endpoint) error { + if len(ep.CaBundle) > 0 { + if err := transportWithCABundle(transport, ep.CaBundle); err != nil { + return err + } + } + if ep.InsecureSkipTLS { + transportWithInsecureTLS(transport) + } + return nil +} + +func newSession(c *client, ep *transport.Endpoint, auth transport.AuthMethod) (*session, error) { + var httpClient *http.Client + + // We need to configure the http transport if there are transport specific + // options present in the endpoint. + if len(ep.CaBundle) > 0 || ep.InsecureSkipTLS { + var transport *http.Transport + // if the client wasn't configured to have a cache for transports then just configure + // the transport and use it directly, otherwise try to use the cache. + if c.transports == nil { + tr, ok := c.c.Transport.(*http.Transport) + if !ok { + return nil, fmt.Errorf("expected underlying client transport to be of type: %s; got: %s", + reflect.TypeOf(transport), reflect.TypeOf(c.c.Transport)) + } + + transport = tr.Clone() + configureTransport(transport, ep) + } else { + transportOpts := transportOptions{ + caBundle: string(ep.CaBundle), + insecureSkipTLS: ep.InsecureSkipTLS, + } + var found bool + transport, found = c.fetchTransport(transportOpts) + + if !found { + transport = c.c.Transport.(*http.Transport).Clone() + configureTransport(transport, ep) + c.addTransport(transportOpts, transport) + } + } + + httpClient = &http.Client{ + Transport: transport, + CheckRedirect: c.c.CheckRedirect, + Jar: c.c.Jar, + Timeout: c.c.Timeout, + } + } else { + httpClient = c.c + } + s := &session{ auth: basicAuthFromEndpoint(ep), - client: c, + client: httpClient, endpoint: ep, } if auth != nil { diff --git a/plumbing/transport/http/common_test.go b/plumbing/transport/http/common_test.go index 4122e62..41188e6 100644 --- a/plumbing/transport/http/common_test.go +++ b/plumbing/transport/http/common_test.go @@ -91,6 +91,60 @@ func (s *ClientSuite) TestNewHTTPError40x(c *C) { "unexpected client error.*") } +func (s *ClientSuite) Test_newSession(c *C) { + cl := NewClientWithOptions(nil, &ClientOptions{ + CacheMaxEntries: 2, + }).(*client) + + insecureEP := s.Endpoint + insecureEP.InsecureSkipTLS = true + session, err := newSession(cl, insecureEP, nil) + c.Assert(err, IsNil) + + sessionTransport := session.client.Transport.(*http.Transport) + c.Assert(sessionTransport.TLSClientConfig.InsecureSkipVerify, Equals, true) + t, ok := cl.fetchTransport(transportOptions{ + insecureSkipTLS: true, + }) + // transport should be cached. + c.Assert(ok, Equals, true) + // cached transport should be the one that's used. + c.Assert(sessionTransport, Equals, t) + + caEndpoint := insecureEP + caEndpoint.CaBundle = []byte("this is the way") + session, err = newSession(cl, caEndpoint, nil) + c.Assert(err, IsNil) + + sessionTransport = session.client.Transport.(*http.Transport) + c.Assert(sessionTransport.TLSClientConfig.InsecureSkipVerify, Equals, true) + c.Assert(sessionTransport.TLSClientConfig.RootCAs, NotNil) + t, ok = cl.fetchTransport(transportOptions{ + insecureSkipTLS: true, + caBundle: "this is the way", + }) + // transport should be cached. + c.Assert(ok, Equals, true) + // cached transport should be the one that's used. + c.Assert(sessionTransport, Equals, t) + + session, err = newSession(cl, caEndpoint, nil) + c.Assert(err, IsNil) + sessionTransport = session.client.Transport.(*http.Transport) + // transport that's going to be used should be cached already. + c.Assert(sessionTransport, Equals, t) + // no new transport got cached. + c.Assert(cl.transports.Len(), Equals, 2) + + // if the cache does not exist, the transport should still be correctly configured. + cl.transports = nil + session, err = newSession(cl, insecureEP, nil) + c.Assert(err, IsNil) + + sessionTransport = session.client.Transport.(*http.Transport) + c.Assert(sessionTransport.TLSClientConfig.InsecureSkipVerify, Equals, true) +} + func (s *ClientSuite) testNewHTTPError(c *C, code int, msg string) { req, _ := http.NewRequest("GET", "foo", nil) res := &http.Response{ diff --git a/plumbing/transport/http/receive_pack.go b/plumbing/transport/http/receive_pack.go index 4d14ff2..4387ecf 100644 --- a/plumbing/transport/http/receive_pack.go +++ b/plumbing/transport/http/receive_pack.go @@ -19,7 +19,7 @@ type rpSession struct { *session } -func newReceivePackSession(c *http.Client, ep *transport.Endpoint, auth transport.AuthMethod) (transport.ReceivePackSession, error) { +func newReceivePackSession(c *client, ep *transport.Endpoint, auth transport.AuthMethod) (transport.ReceivePackSession, error) { s, err := newSession(c, ep, auth) return &rpSession{s}, err } diff --git a/plumbing/transport/http/transport.go b/plumbing/transport/http/transport.go new file mode 100644 index 0000000..cd6787a --- /dev/null +++ b/plumbing/transport/http/transport.go @@ -0,0 +1,38 @@ +package http + +import ( + "net/http" +) + +// transportOptions contains transport specific configuration. +type transportOptions struct { + insecureSkipTLS bool + // []byte is not comparable. + caBundle string +} + +func (c *client) addTransport(opts transportOptions, transport *http.Transport) { + c.m.Lock() + c.transports.Add(opts, transport) + c.m.Unlock() +} + +func (c *client) removeTransport(opts transportOptions) { + c.m.Lock() + c.transports.Remove(opts) + c.m.Unlock() +} + +func (c *client) fetchTransport(opts transportOptions) (*http.Transport, bool) { + c.m.RLock() + t, ok := c.transports.Get(opts) + c.m.RUnlock() + if !ok { + return nil, false + } + transport, ok := t.(*http.Transport) + if !ok { + return nil, false + } + return transport, true +} diff --git a/plumbing/transport/http/upload_pack.go b/plumbing/transport/http/upload_pack.go index e735b3d..4f85145 100644 --- a/plumbing/transport/http/upload_pack.go +++ b/plumbing/transport/http/upload_pack.go @@ -19,7 +19,7 @@ type upSession struct { *session } -func newUploadPackSession(c *http.Client, ep *transport.Endpoint, auth transport.AuthMethod) (transport.UploadPackSession, error) { +func newUploadPackSession(c *client, ep *transport.Endpoint, auth transport.AuthMethod) (transport.UploadPackSession, error) { s, err := newSession(c, ep, auth) return &upSession{s}, err } -- cgit From a830187d90a6bc36f9466c075ed49076f591efa9 Mon Sep 17 00:00:00 2001 From: Sanskar Jaiswal Date: Fri, 28 Apr 2023 17:17:00 +0530 Subject: plumbing: transport/http, add support for custom proxy URLs Add support for custom HTTP and HTTPS proxies for each session. The tests require server certificate and a matching private key to be able to run a TLS server and test HTTPS proxy functionality. The cert and the key are stored in `plumbing/transport/http/testdata/certs` and were generated using the following command: `openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout server.key -out server.crt` Note: All details were left empty, except for the FQDN for which example.com was used. Signed-off-by: Sanskar Jaiswal --- plumbing/transport/http/common.go | 22 +++- .../transport/http/internal/test/proxy_test.go | 72 +++++++++++++ .../transport/http/internal/test/test_utils.go | 43 ++++++++ plumbing/transport/http/proxy_test.go | 119 +++++++++++++++++++++ plumbing/transport/http/testdata/certs/server.crt | 22 ++++ plumbing/transport/http/testdata/certs/server.key | 28 +++++ plumbing/transport/http/transport.go | 2 + 7 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 plumbing/transport/http/internal/test/proxy_test.go create mode 100644 plumbing/transport/http/internal/test/test_utils.go create mode 100644 plumbing/transport/http/proxy_test.go create mode 100644 plumbing/transport/http/testdata/certs/server.crt create mode 100644 plumbing/transport/http/testdata/certs/server.key (limited to 'plumbing') diff --git a/plumbing/transport/http/common.go b/plumbing/transport/http/common.go index 5300341..f9b7a0e 100644 --- a/plumbing/transport/http/common.go +++ b/plumbing/transport/http/common.go @@ -9,6 +9,7 @@ import ( "fmt" "net" "net/http" + "net/url" "reflect" "strconv" "strings" @@ -188,6 +189,10 @@ func transportWithCABundle(transport *http.Transport, caBundle []byte) error { return nil } +func transportWithProxy(transport *http.Transport, proxyURL *url.URL) { + transport.Proxy = http.ProxyURL(proxyURL) +} + func configureTransport(transport *http.Transport, ep *transport.Endpoint) error { if len(ep.CaBundle) > 0 { if err := transportWithCABundle(transport, ep.CaBundle); err != nil { @@ -197,6 +202,14 @@ func configureTransport(transport *http.Transport, ep *transport.Endpoint) error if ep.InsecureSkipTLS { transportWithInsecureTLS(transport) } + + if ep.Proxy.URL != "" { + proxyURL, err := ep.Proxy.FullURL() + if err != nil { + return err + } + transportWithProxy(transport, proxyURL) + } return nil } @@ -205,7 +218,7 @@ func newSession(c *client, ep *transport.Endpoint, auth transport.AuthMethod) (* // We need to configure the http transport if there are transport specific // options present in the endpoint. - if len(ep.CaBundle) > 0 || ep.InsecureSkipTLS { + if len(ep.CaBundle) > 0 || ep.InsecureSkipTLS || ep.Proxy.URL != "" { var transport *http.Transport // if the client wasn't configured to have a cache for transports then just configure // the transport and use it directly, otherwise try to use the cache. @@ -223,6 +236,13 @@ func newSession(c *client, ep *transport.Endpoint, auth transport.AuthMethod) (* caBundle: string(ep.CaBundle), insecureSkipTLS: ep.InsecureSkipTLS, } + if ep.Proxy.URL != "" { + proxyURL, err := ep.Proxy.FullURL() + if err != nil { + return nil, err + } + transportOpts.proxyURL = *proxyURL + } var found bool transport, found = c.fetchTransport(transportOpts) diff --git a/plumbing/transport/http/internal/test/proxy_test.go b/plumbing/transport/http/internal/test/proxy_test.go new file mode 100644 index 0000000..6ae2943 --- /dev/null +++ b/plumbing/transport/http/internal/test/proxy_test.go @@ -0,0 +1,72 @@ +package test + +import ( + "context" + "crypto/tls" + "fmt" + "net" + nethttp "net/http" + "os" + "sync/atomic" + "testing" + + "github.com/elazarl/goproxy" + + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" + + . "gopkg.in/check.v1" +) + +// Hook up gocheck into the "go test" runner. +func Test(t *testing.T) { TestingT(t) } + +type ProxySuite struct{} + +var _ = Suite(&ProxySuite{}) + +var proxiedRequests int32 + +// This test tests proxy support via an env var, i.e. `HTTPS_PROXY`. +// Its located in a separate package because golang caches the value +// of proxy env vars leading to misleading/unexpected test results. +func (s *ProxySuite) TestAdvertisedReferences(c *C) { + proxy := goproxy.NewProxyHttpServer() + proxy.Verbose = true + SetupHTTPSProxy(proxy, &proxiedRequests) + httpsListener, err := net.Listen("tcp", ":0") + c.Assert(err, IsNil) + defer httpsListener.Close() + httpProxyAddr := fmt.Sprintf("localhost:%d", httpsListener.Addr().(*net.TCPAddr).Port) + + proxyServer := nethttp.Server{ + Addr: httpProxyAddr, + Handler: proxy, + // Due to how golang manages http/2 when provided with custom TLS config, + // servers and clients running in the same process leads to issues. + // Ref: https://github.com/golang/go/issues/21336 + TLSConfig: &tls.Config{ + NextProtos: []string{"http/1.1"}, + }, + } + go proxyServer.ServeTLS(httpsListener, "../../testdata/certs/server.crt", "../../testdata/certs/server.key") + defer proxyServer.Close() + os.Setenv("HTTPS_PROXY", fmt.Sprintf("https://user:pass@%s", httpProxyAddr)) + defer os.Unsetenv("HTTPS_PROXY") + + endpoint, err := transport.NewEndpoint("https://github.com/git-fixtures/basic.git") + c.Assert(err, IsNil) + endpoint.InsecureSkipTLS = true + + client := http.DefaultClient + session, err := client.NewUploadPackSession(endpoint, nil) + c.Assert(err, IsNil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + info, err := session.AdvertisedReferencesContext(ctx) + c.Assert(err, IsNil) + c.Assert(info, NotNil) + proxyUsed := atomic.LoadInt32(&proxiedRequests) > 0 + c.Assert(proxyUsed, Equals, true) +} diff --git a/plumbing/transport/http/internal/test/test_utils.go b/plumbing/transport/http/internal/test/test_utils.go new file mode 100644 index 0000000..6665fb3 --- /dev/null +++ b/plumbing/transport/http/internal/test/test_utils.go @@ -0,0 +1,43 @@ +package test + +import ( + "encoding/base64" + "strings" + "sync/atomic" + + "github.com/elazarl/goproxy" +) + +func SetupHTTPSProxy(proxy *goproxy.ProxyHttpServer, proxiedRequests *int32) { + var proxyHandler goproxy.FuncHttpsHandler = func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) { + if strings.Contains(host, "github.com") { + user, pass, _ := ParseBasicAuth(ctx.Req.Header.Get("Proxy-Authorization")) + if user != "user" || pass != "pass" { + return goproxy.RejectConnect, host + } + atomic.AddInt32(proxiedRequests, 1) + return goproxy.OkConnect, host + } + // Reject if it isn't our request. + return goproxy.RejectConnect, host + } + proxy.OnRequest().HandleConnect(proxyHandler) +} + +// adapted from https://github.com/golang/go/blob/2ef70d9d0f98832c8103a7968b195e560a8bb262/src/net/http/request.go#L959 +func ParseBasicAuth(auth string) (username, password string, ok bool) { + const prefix = "Basic " + if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) { + return "", "", false + } + c, err := base64.StdEncoding.DecodeString(auth[len(prefix):]) + if err != nil { + return "", "", false + } + cs := string(c) + username, password, ok = strings.Cut(cs, ":") + if !ok { + return "", "", false + } + return username, password, true +} diff --git a/plumbing/transport/http/proxy_test.go b/plumbing/transport/http/proxy_test.go new file mode 100644 index 0000000..f3024da --- /dev/null +++ b/plumbing/transport/http/proxy_test.go @@ -0,0 +1,119 @@ +package http + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "strings" + "sync/atomic" + + "github.com/elazarl/goproxy" + fixtures "github.com/go-git/go-git-fixtures/v4" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http/internal/test" + + . "gopkg.in/check.v1" +) + +type ProxySuite struct { + u UploadPackSuite + fixtures.Suite +} + +var _ = Suite(&ProxySuite{}) + +var proxiedRequests int32 + +func (s *ProxySuite) TestAdvertisedReferences(c *C) { + s.u.SetUpTest(c) + proxy := goproxy.NewProxyHttpServer() + proxy.Verbose = true + setupHTTPProxy(proxy, &proxiedRequests) + httpListener, err := net.Listen("tcp", ":0") + c.Assert(err, IsNil) + defer httpListener.Close() + + httpProxyAddr := fmt.Sprintf("http://localhost:%d", httpListener.Addr().(*net.TCPAddr).Port) + proxyServer := http.Server{ + Addr: httpProxyAddr, + Handler: proxy, + } + go proxyServer.Serve(httpListener) + defer proxyServer.Close() + + endpoint := s.u.prepareRepository(c, fixtures.Basic().One(), "basic.git") + endpoint.Proxy = transport.ProxyOptions{ + URL: httpProxyAddr, + Username: "user", + Password: "pass", + } + + s.u.Client = NewClient(nil) + session, err := s.u.Client.NewUploadPackSession(endpoint, nil) + c.Assert(err, IsNil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + info, err := session.AdvertisedReferencesContext(ctx) + c.Assert(err, IsNil) + c.Assert(info, NotNil) + proxyUsed := atomic.LoadInt32(&proxiedRequests) > 0 + c.Assert(proxyUsed, Equals, true) + + atomic.StoreInt32(&proxiedRequests, 0) + test.SetupHTTPSProxy(proxy, &proxiedRequests) + httpsListener, err := net.Listen("tcp", ":0") + c.Assert(err, IsNil) + defer httpsListener.Close() + httpsProxyAddr := fmt.Sprintf("https://localhost:%d", httpsListener.Addr().(*net.TCPAddr).Port) + + tlsProxyServer := http.Server{ + Addr: httpsProxyAddr, + Handler: proxy, + // Due to how golang manages http/2 when provided with custom TLS config, + // servers and clients running in the same process leads to issues. + // Ref: https://github.com/golang/go/issues/21336 + TLSConfig: &tls.Config{ + NextProtos: []string{"http/1.1"}, + }, + } + go tlsProxyServer.ServeTLS(httpsListener, "testdata/certs/server.crt", "testdata/certs/server.key") + defer tlsProxyServer.Close() + + endpoint, err = transport.NewEndpoint("https://github.com/git-fixtures/basic.git") + c.Assert(err, IsNil) + endpoint.Proxy = transport.ProxyOptions{ + URL: httpsProxyAddr, + Username: "user", + Password: "pass", + } + endpoint.InsecureSkipTLS = true + + session, err = s.u.Client.NewUploadPackSession(endpoint, nil) + c.Assert(err, IsNil) + + info, err = session.AdvertisedReferencesContext(ctx) + c.Assert(err, IsNil) + c.Assert(info, NotNil) + proxyUsed = atomic.LoadInt32(&proxiedRequests) > 0 + c.Assert(proxyUsed, Equals, true) +} + +func setupHTTPProxy(proxy *goproxy.ProxyHttpServer, proxiedRequests *int32) { + // The request is being forwarded to the local test git server in this handler. + var proxyHandler goproxy.FuncReqHandler = func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { + if strings.Contains(req.Host, "localhost") { + user, pass, _ := test.ParseBasicAuth(req.Header.Get("Proxy-Authorization")) + if user != "user" || pass != "pass" { + return req, goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusUnauthorized, "") + } + atomic.AddInt32(proxiedRequests, 1) + return req, nil + } + // Reject if it isn't our request. + return req, goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusForbidden, "") + } + proxy.OnRequest().Do(proxyHandler) +} diff --git a/plumbing/transport/http/testdata/certs/server.crt b/plumbing/transport/http/testdata/certs/server.crt new file mode 100644 index 0000000..9bdec2c --- /dev/null +++ b/plumbing/transport/http/testdata/certs/server.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDkzCCAnugAwIBAgIUWcuzUyG3EfGsXVUH0BAmnCJyNHswDQYJKoZIhvcNAQEL +BQAwWTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDESMBAGA1UEAwwJbG9jYWxob3N0MB4X +DTIzMDMwNzA3MTgwNloXDTI0MDMwNjA3MTgwNlowWTELMAkGA1UEBhMCQVUxEzAR +BgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5 +IEx0ZDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAvyKX6vJXt1u+WBfBNJByFDAb7msdsk6SiPFlX5uyilaWmlRxvLo1 +GZMjjuQbs4wU6BAoZcgiELnsC9GSyxgrhk7NIW3ud/QD7s8ZxETxFLb0ur6tJj7/ +ETEcU/AKSl1FpeJbGHqGipYp5+0GU0zPDxRYqC2N3+fcGZPQbBwxb1f+MrBjWutb +3eNYTLdPH3W7RUqbunC1KZRJ8XOcU5XZ4qEaMkZYdz1QItxwPnpPuSZs53ga3TDF +zclpQcT8OH2JNwSI6bwlwFJ0Es06manw7XHmgd8anhix9FdsQYaTOW4kqh1iKQ/P +jPG50bdTUEqlOsaa+9av/qf+90npzt3xqQIDAQABo1MwUTAdBgNVHQ4EFgQUqTqb +q+jiJVgwftQS+YLcQWnvTuAwHwYDVR0jBBgwFoAUqTqbq+jiJVgwftQS+YLcQWnv +TuAwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAVUaFSikxyCy1 +4P/ZZgeuR7vEJ5vWBxKPw/jFNZUFWy2Ag32w1BhrDwoYoc1Awg76QF2TqBQAhFNm +ek9aE+L83P/R2UhE9+LHnzwdMXt9HYOI1grONk2z3lMI1y4FCJBxHfGyC/XMoNgZ +qP7UdLgLGTIMN3O1Fww416Hn8BHzxN4o5ZEHJZ6QPMuN8OLk9oVu3yQIq/QcmSDH +GT2RiwT5IJWMUKK1UrV+y3/T9FwW2qqu+LX+coxjk7HgDWy3y66V9ahLBt8kONcr +qK0zutoQ5WPSmvnD2Nr0LVLGXEd7hbQNO7bgjO2YOBtnagUQJt72i/OmvZv8Mfnp +Bu6Qgl5hDw== +-----END CERTIFICATE----- diff --git a/plumbing/transport/http/testdata/certs/server.key b/plumbing/transport/http/testdata/certs/server.key new file mode 100644 index 0000000..9a0cd8f --- /dev/null +++ b/plumbing/transport/http/testdata/certs/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC/Ipfq8le3W75Y +F8E0kHIUMBvuax2yTpKI8WVfm7KKVpaaVHG8ujUZkyOO5BuzjBToEChlyCIQuewL +0ZLLGCuGTs0hbe539APuzxnERPEUtvS6vq0mPv8RMRxT8ApKXUWl4lsYeoaKlinn +7QZTTM8PFFioLY3f59wZk9BsHDFvV/4ysGNa61vd41hMt08fdbtFSpu6cLUplEnx +c5xTldnioRoyRlh3PVAi3HA+ek+5JmzneBrdMMXNyWlBxPw4fYk3BIjpvCXAUnQS +zTqZqfDtceaB3xqeGLH0V2xBhpM5biSqHWIpD8+M8bnRt1NQSqU6xpr71q/+p/73 +SenO3fGpAgMBAAECggEAQUjenQhzv5Rdmpdajcq8vHqGP9Rki0/dK1tQpex3elsD +C+nGA5GSq46feaIeeCBjz7QdKE7Im+/1WUAXJLm3vCNUW5PB/UTixwIEKg7mTY4E +X3jbiZHA661boKv/x9C+BmAff2fyZonN/ILwQymcG+l2MtOEfzMh8baUXSjwFbhg +B08u4iXjee0y9I0CGMYWfasHLOIuhACCFKtqnvdQp8B82g8eSPhme5IjfPP8KZVr +00n6z8m00HVk6/yYJ8pVZ82j3T+wH6IqvlvaC320sbto8YXV6i8GWHaJumzU4/9s +IRm4459E+NmNcLNY/TCu89zsfrgNirN+qFfvJIOTxQKBgQDtME8s4UP0MhGuJ2lD +1HD64fAxMC6Xp/QSzY91Yn79UNssUUV+IwjuUnLIz3U8DBs/QETLm7CkNtI7h5m5 +dBdeBBzCRGxhe8WqRfvceu4s0zr08ZkDaKLjFsBSnBsXZhKAAuRqBjnGAoAiKgVa +WpEAug00ThhQjipSY9tO9NSBawKBgQDOSz+8m2HJFktEdSctKIB9DesqlAg7YCyy +dHzywP0/r7wEvsCN7xPgCT5g8JBkRaFvLLKgw7gMKAUx8V2iwizEoDCAs/pbTWji +uZwPC8lWtbkpBMQIaP4Wap+GyFQJKv1/qZduwpkwkj+ok+m3WwIW55VFGyLn3XGG +VcLZm83aOwKBgQDXXI/nXjqHVZb8HEjWD+Ttx4yB/Q+xIAzbrc3edap8c5guKzUA +DOulCTOz5bq65PsweTh970V6NVS6PKt12lUFRpKeSeZmtS2LJ7RCQ1RTWxAjK+MV +V0LfEt9ZouhuXH3bwcSICFMY2VhirOjjW2xhzo0Cuw4UxqDi4kxU6rSxNQKBgQCI +sn5KmV/jot0/QK40E0mJFEcHkM4foiwcGGqPZWiq4eUh89CefJTb+OQX0nCrsSQ3 +ChRXyTlU/NPsczcL2cVWiZt6PUihZZsh2cJaigHhbkuCrcDEneX4rrCE3IwrAwy1 +oohRAawG7nI2X8UYFbs9uDlGcKPhpvBKBtw13DM87wKBgE8fOiFoTph//6piU7dV +pN33UfhPcAFwsIzxAH6Ljo6BYx2hfPRCxI2g0wchk6ydbDecLgMwVgugdJZ6+tRf +P+YV3wEwPcWOvWby3+EmJh0cXUTl6ZMA+eR4pvCi6kf2xJf9dRmEeNNhOuzn9Y0J +cT9yhBFG4iejKP0iTwET1JKY +-----END PRIVATE KEY----- diff --git a/plumbing/transport/http/transport.go b/plumbing/transport/http/transport.go index cd6787a..052f3c8 100644 --- a/plumbing/transport/http/transport.go +++ b/plumbing/transport/http/transport.go @@ -2,6 +2,7 @@ package http import ( "net/http" + "net/url" ) // transportOptions contains transport specific configuration. @@ -9,6 +10,7 @@ type transportOptions struct { insecureSkipTLS bool // []byte is not comparable. caBundle string + proxyURL url.URL } func (c *client) addTransport(opts transportOptions, transport *http.Transport) { -- cgit