From 3ac21241bf09694a3cae452dd7fe1dd383fc21fc Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Fri, 6 Mar 2026 19:06:16 -0800 Subject: [PATCH 01/33] fix formatting and error messages --- cmd/src/main.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cmd/src/main.go b/cmd/src/main.go index 8bd1a5fe77..0241271f90 100644 --- a/cmd/src/main.go +++ b/cmd/src/main.go @@ -7,6 +7,7 @@ import ( "io" "log" "net" + "net/http" "net/url" "os" "path/filepath" @@ -288,6 +289,14 @@ func readConfig() (*config, error) { } else { return nil, errors.Newf("invalid proxy endpoint: %s", proxyStr) } + } else { + // no SRC_PROXY; check for the standard proxy env variables HTTP_PROXY, HTTPS_PROXY, and NO_PROXY + if u, err := http.ProxyFromEnvironment(&http.Request{URL: cfg.endpointURL}); err != nil { + // when there's an error, the value for the env variable is not a legit URL + return nil, errors.Newf("invalid HTTP_PROXY or HTTPS_PROXY value: %w", err) + } else { + cfg.proxyURL = u + } } cfg.additionalHeaders = parseAdditionalHeaders() @@ -319,7 +328,7 @@ func isValidUnixSocket(path string) (bool, error) { if os.IsNotExist(err) { return false, nil } - return false, errors.Newf("not a UNIX Domain Socket: %v: %w", path, err) + return false, errors.Newf("not a UNIX domain socket: %v: %w", path, err) } defer conn.Close() From b2f1e4e61816f9a0a46bbe20b8a88c7d126e66e0 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Tue, 24 Feb 2026 21:28:05 -0800 Subject: [PATCH 02/33] add function to read HTTP_PROXY/HTTPS_PROXY/NO_PROXY environment variables --- internal/api/api.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/api/api.go b/internal/api/api.go index c38af19d25..5f8c701297 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -119,6 +119,23 @@ func buildTransport(opts ClientOpts, flags *Flags) http.RoundTripper { return transport } +// envProxyURL resolves the proxy URL +// from standard HTTP_PROXY/HTTPS_PROXY/NO_PROXY +// environment variables for the given endpoint. +// Returns nil if the endpoint is not a valid URL, +// no proxy is configured, or the endpoint is excluded. +func envProxyURL(endpoint string) *url.URL { + u, err := url.Parse(endpoint) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil + } + proxyURL, err := http.ProxyFromEnvironment(&http.Request{URL: u}) + if err != nil || proxyURL == nil { + return nil + } + return proxyURL +} + // NewClient creates a new API client. func NewClient(opts ClientOpts) Client { if opts.Out == nil { From a7a2f599bb6b6bb23dc8a6889f878483fddb54f7 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Tue, 24 Feb 2026 21:29:13 -0800 Subject: [PATCH 03/33] use new function to read proxy settings from environment, preferring SRC_PROXY if present --- internal/api/api.go | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 5f8c701297..60e7b4e7f0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -90,33 +90,37 @@ type ClientOpts struct { } func buildTransport(opts ClientOpts, flags *Flags) http.RoundTripper { - var transport http.RoundTripper - { - tp := http.DefaultTransport.(*http.Transport).Clone() + transport := http.DefaultTransport.(*http.Transport).Clone() - if flags.insecureSkipVerify != nil && *flags.insecureSkipVerify { - tp.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - } - - if tp.TLSClientConfig == nil { - tp.TLSClientConfig = &tls.Config{} - } + if flags.insecureSkipVerify != nil && *flags.insecureSkipVerify { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } - if opts.ProxyURL != nil || opts.ProxyPath != "" { - tp = withProxyTransport(tp, opts.ProxyURL, opts.ProxyPath) - } + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} + } - transport = tp + if opts.ProxyURL != nil || opts.ProxyPath != "" { + // Explicit SRC_PROXY configuration takes precedence. + transport = withProxyTransport(transport, opts.ProxyURL, opts.ProxyPath) + } else if proxyURL := envProxyURL(opts.EndpointURL.String()); proxyURL != nil && proxyURL.Scheme == "https" { + // For HTTPS proxies discovered via standard env vars, use our custom + // dialer to force HTTP/1.1 for the CONNECT tunnel. Many proxy servers + // don't support HTTP/2 CONNECT, which Go may negotiate via ALPN when + // TLS-connecting to an https:// proxy. + transport = withProxyTransport(transport, proxyURL, "") } + // For http:// and socks5:// proxies from standard env vars, the cloned + // transport's default Proxy handles them correctly without intervention. + var rt http.RoundTripper = transport if opts.AccessToken == "" && opts.OAuthToken != nil { - transport = &oauth.Transport{ + rt = &oauth.Transport{ Base: transport, Token: opts.OAuthToken, } } - - return transport + return rt } // envProxyURL resolves the proxy URL From e929425f493e64dc7098d9307e7e65fc02689947 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Tue, 24 Feb 2026 21:33:49 -0800 Subject: [PATCH 04/33] use http.Request instead of manually building the request, and use Request.Write to ensure correctness --- internal/api/proxy.go | 37 ++++++++----------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/internal/api/proxy.go b/internal/api/proxy.go index 9589b9beb5..611fa8ec2a 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -66,39 +66,18 @@ func withProxyTransport(baseTransport *http.Transport, proxyURL *url.URL, proxyP return nil, err } - // this is the whole point of manually dialing the HTTP(S) proxy: - // being able to force HTTP/1. - // When relying on Transport.Proxy, the protocol is always HTTP/2, - // but many proxy servers don't support HTTP/2. - // We don't want to disable HTTP/2 in general because we want to use it when - // connecting to the Sourcegraph API, using HTTP/1 for the proxy connection only. - protocol := "HTTP/1.1" - - // CONNECT is the HTTP method used to set up a tunneling connection with a proxy - method := "CONNECT" - - // Manually writing out the HTTP commands because it's not complicated, - // and http.Request has some janky behavior: - // - ignores the Proto field and hard-codes the protocol to HTTP/1.1 - // - ignores the Host Header (Header.Set("Host", host)) and uses URL.Host instead. - // - When the Host field is set, overrides the URL field - connectReq := fmt.Sprintf("%s %s %s\r\n", method, addr, protocol) - - // A Host header is required per RFC 2616, section 14.23 - connectReq += fmt.Sprintf("Host: %s\r\n", addr) - - // use authentication if proxy credentials are present + connectReq := &http.Request{ + Method: "CONNECT", + URL: &url.URL{Opaque: addr}, + Host: addr, + Header: make(http.Header), + } if proxyURL.User != nil { password, _ := proxyURL.User.Password() auth := base64.StdEncoding.EncodeToString([]byte(proxyURL.User.Username() + ":" + password)) - connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", auth) + connectReq.Header.Set("Proxy-Authorization", "Basic "+auth) } - - // finish up with an extra carriage return + newline, as per RFC 7230, section 3 - connectReq += "\r\n" - - // Send the CONNECT request to the proxy to establish the tunnel - if _, err := conn.Write([]byte(connectReq)); err != nil { + if err := connectReq.Write(conn); err != nil { conn.Close() return nil, err } From 9993da2eabf268d17f730689f5e6a00bb3dae2b0 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Tue, 24 Feb 2026 21:35:58 -0800 Subject: [PATCH 05/33] commit to a buffered reader for the proxy connection so we avoid nasty edge cases of silently dropped/ignored pieces of the response from the proxy --- internal/api/proxy.go | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/internal/api/proxy.go b/internal/api/proxy.go index 611fa8ec2a..c4a53fbaf9 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -11,6 +11,15 @@ import ( "net/url" ) +type connWithBufferedReader struct { + net.Conn + r *bufio.Reader +} + +func (c *connWithBufferedReader) Read(p []byte) (int, error) { + return c.r.Read(p) +} + // withProxyTransport modifies the given transport to handle proxying of unix, socks5 and http connections. // // Note: baseTransport is considered to be a clone created with transport.Clone() @@ -82,18 +91,22 @@ func withProxyTransport(baseTransport *http.Transport, proxyURL *url.URL, proxyP return nil, err } - // Read and check the response from the proxy - resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, nil) if err != nil { conn.Close() return nil, err } if resp.StatusCode != http.StatusOK { + // For non-200, it's safe/appropriate to close the body (it’s a real response body here). + // Try to read a bit (4k bytes) to include in the error message. + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + resp.Body.Close() conn.Close() - return nil, fmt.Errorf("failed to connect to proxy %v: %v", proxyURL, resp.Status) + return nil, fmt.Errorf("failed to connect to proxy %s: %s: %q", proxyURL.Redacted(), resp.Status, b) } - resp.Body.Close() - return conn, nil + // 200 CONNECT: do NOT resp.Body.Close(); it would interfere with the tunnel. + return &connWithBufferedReader{Conn: conn, r: br}, nil } dialTLS := func(ctx context.Context, network, addr string) (net.Conn, error) { // Dial the underlying connection through the proxy From 4c2de90f40a031f39fc4257b69743aec816fb0a9 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Tue, 24 Feb 2026 21:40:14 -0800 Subject: [PATCH 06/33] clone the transport TLS config instead of creating a new one, and make sure it'll try HTTP/2 by setting NextProtos --- internal/api/proxy.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/api/proxy.go b/internal/api/proxy.go index c4a53fbaf9..d2048d7a59 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -33,12 +33,17 @@ func withProxyTransport(baseTransport *http.Transport, proxyURL *url.URL, proxyP if err != nil { return nil, err } - tlsConn := tls.Client(conn, &tls.Config{ - ServerName: host, - // Pull InsecureSkipVerify from the target host transport - // so that insecure-skip-verify flag settings are honored for the proxy server - InsecureSkipVerify: baseTransport.TLSClientConfig.InsecureSkipVerify, - }) + cfg := baseTransport.TLSClientConfig.Clone() + if cfg.ServerName == "" { + cfg.ServerName = host + } + // Preserve HTTP/2 negotiation to the origin when ForceAttemptHTTP2 + // is enabled. Without this, the manual TLS handshake would not + // advertise h2 via ALPN, silently forcing HTTP/1.1. + if baseTransport.ForceAttemptHTTP2 && len(cfg.NextProtos) == 0 { + cfg.NextProtos = []string{"h2", "http/1.1"} + } + tlsConn := tls.Client(conn, cfg) if err := tlsConn.HandshakeContext(ctx); err != nil { return nil, err } From bae69a7eb95b42ed1227a32ae2ed3bf292bbd564 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Tue, 24 Feb 2026 21:40:30 -0800 Subject: [PATCH 07/33] fix some comments --- internal/api/proxy.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/proxy.go b/internal/api/proxy.go index d2048d7a59..dd171beeef 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -24,8 +24,8 @@ func (c *connWithBufferedReader) Read(p []byte) (int, error) { // // Note: baseTransport is considered to be a clone created with transport.Clone() // -// - If a the proxyPath is not empty, a unix socket proxy is created. -// - Otherwise, the proxyURL is used to determine if we should proxy socks5 / http connections +// - If proxyPath is not empty, a unix socket proxy is created. +// - Otherwise, proxyURL is used to determine if we should proxy socks5 / http connections func withProxyTransport(baseTransport *http.Transport, proxyURL *url.URL, proxyPath string) *http.Transport { handshakeTLS := func(ctx context.Context, conn net.Conn, addr string) (net.Conn, error) { // Extract the hostname (without the port) for TLS SNI From fb16c50c098d8c7adad0dcff5bd452961f87046d Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Tue, 24 Feb 2026 21:42:45 -0800 Subject: [PATCH 08/33] whoops, forgot to commit the io import --- internal/api/proxy.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/api/proxy.go b/internal/api/proxy.go index dd171beeef..47b5e75b76 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "encoding/base64" "fmt" + "io" "net" "net/http" "net/url" From b3895b34f23a558aed0abf863e8aaae2fcec1b33 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Tue, 24 Feb 2026 21:45:03 -0800 Subject: [PATCH 09/33] dial the proxy, ensuring http/1.1 instead of http/2 for TLS-enabled proxy --- internal/api/proxy.go | 48 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/internal/api/proxy.go b/internal/api/proxy.go index 47b5e75b76..1293406c0b 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -21,6 +21,19 @@ func (c *connWithBufferedReader) Read(p []byte) (int, error) { return c.r.Read(p) } +// proxyDialAddr returns proxyURL.Host with a default port appended if one is +// not already present (443 for https, 80 for http). +func proxyDialAddr(proxyURL *url.URL) string { + addr := proxyURL.Host + if _, _, err := net.SplitHostPort(addr); err != nil { + if proxyURL.Scheme == "https" { + return net.JoinHostPort(addr, "443") + } + return net.JoinHostPort(addr, "80") + } + return addr +} + // withProxyTransport modifies the given transport to handle proxying of unix, socks5 and http connections. // // Note: baseTransport is considered to be a clone created with transport.Clone() @@ -74,9 +87,38 @@ func withProxyTransport(baseTransport *http.Transport, proxyURL *url.URL, proxyP baseTransport.Proxy = http.ProxyURL(proxyURL) case "http", "https": dial := func(ctx context.Context, network, addr string) (net.Conn, error) { - // Dial the proxy - d := net.Dialer{} - conn, err := d.DialContext(ctx, "tcp", proxyURL.Host) + // Dial the proxy. For https:// proxies, we TLS-connect to the + // proxy itself and force ALPN to HTTP/1.1 to prevent Go from + // negotiating HTTP/2 for the CONNECT tunnel. Many proxy servers + // don't support HTTP/2 CONNECT, and Go's default Transport.Proxy + // would negotiate h2 via ALPN when TLS-connecting to an https:// + // proxy, causing "bogus greeting" errors. For http:// proxies, + // CONNECT is always HTTP/1.1 over plain TCP so this isn't needed. + // The target connection (e.g. to sourcegraph.com) still negotiates + // HTTP/2 normally through the established tunnel. + proxyAddr := proxyDialAddr(proxyURL) + + var conn net.Conn + var err error + if proxyURL.Scheme == "https" { + raw, dialErr := (&net.Dialer{}).DialContext(ctx, "tcp", proxyAddr) + if dialErr != nil { + return nil, dialErr + } + cfg := baseTransport.TLSClientConfig.Clone() + cfg.NextProtos = []string{"http/1.1"} + if cfg.ServerName == "" { + cfg.ServerName = proxyURL.Hostname() + } + tlsConn := tls.Client(raw, cfg) + if err := tlsConn.HandshakeContext(ctx); err != nil { + raw.Close() + return nil, err + } + conn = tlsConn + } else { + conn, err = (&net.Dialer{}).DialContext(ctx, "tcp", proxyAddr) + } if err != nil { return nil, err } From 69763d939a42b993675917d330de4b50b20ce632 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Wed, 25 Feb 2026 13:46:54 -0800 Subject: [PATCH 10/33] add proxy tests --- internal/api/proxy_test.go | 394 +++++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 internal/api/proxy_test.go diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go new file mode 100644 index 0000000000..402a807c1b --- /dev/null +++ b/internal/api/proxy_test.go @@ -0,0 +1,394 @@ +package api + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// startCONNECTProxy starts an HTTP or HTTPS CONNECT proxy on a random port. +// It returns the proxy URL and a channel that receives the protocol observed by +// the proxy handler for each CONNECT request. +func startCONNECTProxy(t *testing.T, useTLS bool) (proxyURL *url.URL, obsCh <-chan string) { + t.Helper() + + ch := make(chan string, 10) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case ch <- r.Proto: + default: + } + + if r.Method != http.MethodConnect { + http.Error(w, "expected CONNECT", http.StatusMethodNotAllowed) + return + } + + destConn, err := net.DialTimeout("tcp", r.Host, 10*time.Second) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer destConn.Close() + + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijacking not supported", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + clientConn, _, err := hijacker.Hijack() + if err != nil { + return + } + defer clientConn.Close() + + done := make(chan struct{}, 2) + go func() { io.Copy(destConn, clientConn); done <- struct{}{} }() + go func() { io.Copy(clientConn, destConn); done <- struct{}{} }() + <-done + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("proxy listen: %v", err) + } + + srv := &http.Server{Handler: handler} + + if useTLS { + cert := generateTestCert(t, "127.0.0.1") + srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} + go srv.ServeTLS(ln, "", "") + } else { + go srv.Serve(ln) + } + t.Cleanup(func() { srv.Close() }) + + scheme := "http" + if useTLS { + scheme = "https" + } + pURL, _ := url.Parse(fmt.Sprintf("%s://%s", scheme, ln.Addr().String())) + return pURL, ch +} + +// startCONNECTProxyWithAuth is like startCONNECTProxy but requires +// Proxy-Authorization with the given username and password. +func startCONNECTProxyWithAuth(t *testing.T, useTLS bool, wantUser, wantPass string) (proxyURL *url.URL) { + t.Helper() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + http.Error(w, "expected CONNECT", http.StatusMethodNotAllowed) + return + } + + authHeader := r.Header.Get("Proxy-Authorization") + wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(wantUser+":"+wantPass)) + if authHeader != wantAuth { + http.Error(w, "proxy auth required", http.StatusProxyAuthRequired) + return + } + + destConn, err := net.DialTimeout("tcp", r.Host, 10*time.Second) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer destConn.Close() + + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijacking not supported", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + clientConn, _, err := hijacker.Hijack() + if err != nil { + return + } + defer clientConn.Close() + + done := make(chan struct{}, 2) + go func() { io.Copy(destConn, clientConn); done <- struct{}{} }() + go func() { io.Copy(clientConn, destConn); done <- struct{}{} }() + <-done + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("proxy listen: %v", err) + } + + srv := &http.Server{Handler: handler} + + if useTLS { + cert := generateTestCert(t, "127.0.0.1") + srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} + go srv.ServeTLS(ln, "", "") + } else { + go srv.Serve(ln) + } + t.Cleanup(func() { srv.Close() }) + + scheme := "http" + if useTLS { + scheme = "https" + } + pURL, _ := url.Parse(fmt.Sprintf("%s://%s@%s", scheme, url.UserPassword(wantUser, wantPass).String(), ln.Addr().String())) + return pURL +} + +func generateTestCert(t *testing.T, host string) tls.Certificate { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: host}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(1 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP(host)}, + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create cert: %v", err) + } + return tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + } +} + +// newTestTransport creates a base transport suitable for proxy tests. +func newTestTransport() *http.Transport { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + return transport +} + +// startTargetServer starts an HTTPS server (with HTTP/2 enabled) that +// responds with "ok" to GET /. +func startTargetServer(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "ok") + })) + srv.EnableHTTP2 = true + srv.StartTLS() + t.Cleanup(srv.Close) + return srv +} + +func TestWithProxyTransport_HTTPProxy(t *testing.T) { + target := startTargetServer(t) + proxyURL, obsCh := startCONNECTProxy(t, false) + + transport := withProxyTransport(newTestTransport(), proxyURL, "") + client := &http.Client{Transport: transport, Timeout: 10 * time.Second} + + resp, err := client.Get(target.URL) + if err != nil { + t.Fatalf("GET through http proxy: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + if got := strings.TrimSpace(string(body)); got != "ok" { + t.Errorf("expected body 'ok', got %q", got) + } + + select { + case proto := <-obsCh: + if proto != "HTTP/1.1" { + t.Errorf("expected proxy to see HTTP/1.1 CONNECT, got %s", proto) + } + case <-time.After(2 * time.Second): + t.Fatal("proxy handler was never invoked") + } +} + +func TestWithProxyTransport_HTTPSProxy(t *testing.T) { + target := startTargetServer(t) + proxyURL, obsCh := startCONNECTProxy(t, true) + + transport := withProxyTransport(newTestTransport(), proxyURL, "") + client := &http.Client{Transport: transport, Timeout: 10 * time.Second} + + resp, err := client.Get(target.URL) + if err != nil { + t.Fatalf("GET through https proxy: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + if got := strings.TrimSpace(string(body)); got != "ok" { + t.Errorf("expected body 'ok', got %q", got) + } + + select { + case proto := <-obsCh: + if proto != "HTTP/1.1" { + t.Errorf("expected proxy to see HTTP/1.1 CONNECT, got %s", proto) + } + case <-time.After(2 * time.Second): + t.Fatal("proxy handler was never invoked") + } +} + +func TestWithProxyTransport_ProxyAuth(t *testing.T) { + target := startTargetServer(t) + + t.Run("http proxy with auth", func(t *testing.T) { + proxyURL := startCONNECTProxyWithAuth(t, false, "user", "pass") + transport := withProxyTransport(newTestTransport(), proxyURL, "") + client := &http.Client{Transport: transport, Timeout: 10 * time.Second} + + resp, err := client.Get(target.URL) + if err != nil { + t.Fatalf("GET through authenticated http proxy: %v", err) + } + defer resp.Body.Close() + io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + }) + + t.Run("https proxy with auth", func(t *testing.T) { + proxyURL := startCONNECTProxyWithAuth(t, true, "user", "s3cret") + transport := withProxyTransport(newTestTransport(), proxyURL, "") + client := &http.Client{Transport: transport, Timeout: 10 * time.Second} + + resp, err := client.Get(target.URL) + if err != nil { + t.Fatalf("GET through authenticated https proxy: %v", err) + } + defer resp.Body.Close() + io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + }) +} + +func TestWithProxyTransport_HTTPSProxy_HTTP2ToOrigin(t *testing.T) { + // Verify that when tunneling through an HTTPS proxy, the connection to + // the origin target still negotiates HTTP/2 (not downgraded to HTTP/1.1). + target := startTargetServer(t) + proxyURL, _ := startCONNECTProxy(t, true) + + transport := withProxyTransport(newTestTransport(), proxyURL, "") + client := &http.Client{Transport: transport, Timeout: 10 * time.Second} + + resp, err := client.Get(target.URL) + if err != nil { + t.Fatalf("GET through https proxy: %v", err) + } + defer resp.Body.Close() + io.ReadAll(resp.Body) + + if resp.Proto != "HTTP/2.0" { + t.Errorf("expected HTTP/2.0 to origin, got %s", resp.Proto) + } +} + +func TestWithProxyTransport_ProxyRejectsConnect(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + wantStatus string + }{ + {"407 proxy auth required", http.StatusProxyAuthRequired, "proxy auth required", "407 Proxy Authentication Required"}, + {"403 forbidden", http.StatusForbidden, "access denied by policy", "403 Forbidden"}, + {"502 bad gateway", http.StatusBadGateway, "upstream unreachable", "502 Bad Gateway"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Start a proxy that always rejects CONNECT with the given status. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, tt.body, tt.statusCode) + })} + go srv.Serve(ln) + t.Cleanup(func() { srv.Close() }) + + proxyURL, _ := url.Parse(fmt.Sprintf("http://%s", ln.Addr().String())) + transport := withProxyTransport(newTestTransport(), proxyURL, "") + client := &http.Client{Transport: transport, Timeout: 10 * time.Second} + + _, err = client.Get("https://example.com") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantStatus) { + t.Errorf("error should contain status %q, got: %v", tt.wantStatus, err) + } + if !strings.Contains(err.Error(), tt.body) { + t.Errorf("error should contain body %q, got: %v", tt.body, err) + } + }) + } +} + + +func TestProxyHostPort(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + {"https with port", "https://proxy.example.com:8443", "proxy.example.com:8443"}, + {"https without port", "https://proxy.example.com", "proxy.example.com:443"}, + {"http with port", "http://proxy.example.com:8080", "proxy.example.com:8080"}, + {"http without port", "http://proxy.example.com", "proxy.example.com:80"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := url.Parse(tt.url) + if err != nil { + t.Fatalf("parse URL: %v", err) + } + got := proxyDialAddr(u) + if got != tt.want { + t.Errorf("proxyHostPort(%s) = %q, want %q", tt.url, got, tt.want) + } + }) + } +} From 6031e19f71e31341452aa46a3e596830a1ab74d8 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Wed, 25 Feb 2026 15:14:06 -0800 Subject: [PATCH 11/33] change name of test to match production method --- internal/api/proxy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go index 402a807c1b..74e1f75152 100644 --- a/internal/api/proxy_test.go +++ b/internal/api/proxy_test.go @@ -368,7 +368,7 @@ func TestWithProxyTransport_ProxyRejectsConnect(t *testing.T) { } -func TestProxyHostPort(t *testing.T) { +func TestProxyDialAddr(t *testing.T) { tests := []struct { name string url string From 8853c80b24cc7157b3c0f787a13511d0242ffb55 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Wed, 25 Feb 2026 17:30:14 -0800 Subject: [PATCH 12/33] add 10ms delay to test proxy server startup to try to fix ubuntu tests --- internal/api/proxy_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go index 74e1f75152..402291bf75 100644 --- a/internal/api/proxy_test.go +++ b/internal/api/proxy_test.go @@ -76,6 +76,8 @@ func startCONNECTProxy(t *testing.T, useTLS bool) (proxyURL *url.URL, obsCh <-ch cert := generateTestCert(t, "127.0.0.1") srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} go srv.ServeTLS(ln, "", "") + // Give the TLS server a moment to start up to avoid race conditions + time.Sleep(10 * time.Millisecond) } else { go srv.Serve(ln) } @@ -144,6 +146,8 @@ func startCONNECTProxyWithAuth(t *testing.T, useTLS bool, wantUser, wantPass str cert := generateTestCert(t, "127.0.0.1") srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} go srv.ServeTLS(ln, "", "") + // Give the TLS server a moment to start up to avoid race conditions + time.Sleep(10 * time.Millisecond) } else { go srv.Serve(ln) } From bd3b210590e9d5ea671726293f2f2288ee42c25e Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Wed, 25 Feb 2026 17:38:19 -0800 Subject: [PATCH 13/33] go-lint.sh --- internal/api/proxy_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go index 402291bf75..0e875200d7 100644 --- a/internal/api/proxy_test.go +++ b/internal/api/proxy_test.go @@ -371,7 +371,6 @@ func TestWithProxyTransport_ProxyRejectsConnect(t *testing.T) { } } - func TestProxyDialAddr(t *testing.T) { tests := []struct { name string From 7016f062c8c0f9156083fb6dc73d107868d099c4 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Wed, 25 Feb 2026 18:23:11 -0800 Subject: [PATCH 14/33] wait for test proxy to startup --- internal/api/proxy_test.go | 43 ++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go index 0e875200d7..003549475b 100644 --- a/internal/api/proxy_test.go +++ b/internal/api/proxy_test.go @@ -18,8 +18,41 @@ import ( "strings" "testing" "time" + "context" ) +// waitForServerReady polls the server until it's ready to accept connections +func waitForServerReady(t *testing.T, addr string, useTLS bool, timeout time.Duration) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + for { + select { + case <-ctx.Done(): + t.Fatalf("server at %s did not become ready within %v", addr, timeout) + default: + } + + var conn net.Conn + var err error + + if useTLS { + conn, err = tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true}) + } else { + conn, err = net.Dial("tcp", addr) + } + + if err == nil { + conn.Close() + return // Server is ready + } + + time.Sleep(1 * time.Millisecond) + } +} + // startCONNECTProxy starts an HTTP or HTTPS CONNECT proxy on a random port. // It returns the proxy URL and a channel that receives the protocol observed by // the proxy handler for each CONNECT request. @@ -76,13 +109,14 @@ func startCONNECTProxy(t *testing.T, useTLS bool) (proxyURL *url.URL, obsCh <-ch cert := generateTestCert(t, "127.0.0.1") srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} go srv.ServeTLS(ln, "", "") - // Give the TLS server a moment to start up to avoid race conditions - time.Sleep(10 * time.Millisecond) } else { go srv.Serve(ln) } t.Cleanup(func() { srv.Close() }) + // Wait for the server to be ready + waitForServerReady(t, ln.Addr().String(), useTLS, 5*time.Second) + scheme := "http" if useTLS { scheme = "https" @@ -146,13 +180,14 @@ func startCONNECTProxyWithAuth(t *testing.T, useTLS bool, wantUser, wantPass str cert := generateTestCert(t, "127.0.0.1") srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} go srv.ServeTLS(ln, "", "") - // Give the TLS server a moment to start up to avoid race conditions - time.Sleep(10 * time.Millisecond) } else { go srv.Serve(ln) } t.Cleanup(func() { srv.Close() }) + // Wait for the server to be ready + waitForServerReady(t, ln.Addr().String(), useTLS, 5*time.Second) + scheme := "http" if useTLS { scheme = "https" From 8a8032a1a6d8a1b919ac82b2abe9e7e9147d79cb Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Wed, 25 Feb 2026 18:26:51 -0800 Subject: [PATCH 15/33] go-lint.sh --- internal/api/proxy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go index 003549475b..8a243f4e9f 100644 --- a/internal/api/proxy_test.go +++ b/internal/api/proxy_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -18,7 +19,6 @@ import ( "strings" "testing" "time" - "context" ) // waitForServerReady polls the server until it's ready to accept connections From b4724eb708aead5f4619ac2e986b807df66e174e Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Fri, 27 Feb 2026 11:27:10 -0800 Subject: [PATCH 16/33] parse the endpoint into a URL up front, and gather the proxy from the env up front so that typos and malformed urls will surface immediately --- cmd/src/main.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/src/main.go b/cmd/src/main.go index 0241271f90..1fe83b4752 100644 --- a/cmd/src/main.go +++ b/cmd/src/main.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "flag" + "fmt" "io" "log" "net" @@ -170,7 +171,8 @@ func (c *config) apiClient(flags *api.Flags, out io.Writer) api.Client { return api.NewClient(opts) } -// readConfig reads the config file from the given path. +// readConfig reads the config from the standard config file, the (deprecated) user-specified config file, +// the environment variables, and the (deprecated) command-line flags. func readConfig() (*config, error) { cfgFile := *configPath userSpecified := *configPath != "" @@ -283,7 +285,7 @@ func readConfig() (*config, error) { return nil, errors.Newf("invalid proxy configuration: %w", err) } if !isValidUDS { - return nil, errors.Newf("invalid proxy socket: %s", path) + return nil, errors.Newf("Invalid proxy socket: %s", path) } cfg.proxyPath = path } else { From 50ea28b3415a962cf14a532db75976b81f92bba1 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Fri, 27 Feb 2026 11:28:24 -0800 Subject: [PATCH 17/33] use the parsed endpoint url and consolidate proxy handling because the proxy in the config is now the one gathered from either SRC_PROXY or the standard env variables --- cmd/src/main.go | 1 - internal/api/api.go | 34 ++++++++-------------------------- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/cmd/src/main.go b/cmd/src/main.go index 1fe83b4752..3f542b97c5 100644 --- a/cmd/src/main.go +++ b/cmd/src/main.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "flag" - "fmt" "io" "log" "net" diff --git a/internal/api/api.go b/internal/api/api.go index 60e7b4e7f0..4b51801954 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -100,17 +100,16 @@ func buildTransport(opts ClientOpts, flags *Flags) http.RoundTripper { transport.TLSClientConfig = &tls.Config{} } - if opts.ProxyURL != nil || opts.ProxyPath != "" { - // Explicit SRC_PROXY configuration takes precedence. + if opts.ProxyPath != "" || (opts.ProxyURL != nil && opts.ProxyURL.Scheme == "https") { + // Use our custom dialer for: + // - unix socket proxies + // - TLS=enabled proxies, to force HTTP/1.1 for the CONNECT tunnel. + // Many TLS-enabled proxy servers don't support HTTP/2 CONNECT, + // which Go may negotiate via ALPN, resulting in connection errors. transport = withProxyTransport(transport, opts.ProxyURL, opts.ProxyPath) - } else if proxyURL := envProxyURL(opts.EndpointURL.String()); proxyURL != nil && proxyURL.Scheme == "https" { - // For HTTPS proxies discovered via standard env vars, use our custom - // dialer to force HTTP/1.1 for the CONNECT tunnel. Many proxy servers - // don't support HTTP/2 CONNECT, which Go may negotiate via ALPN when - // TLS-connecting to an https:// proxy. - transport = withProxyTransport(transport, proxyURL, "") } - // For http:// and socks5:// proxies from standard env vars, the cloned + + // For http:// and socks5:// proxies, the cloned // transport's default Proxy handles them correctly without intervention. var rt http.RoundTripper = transport @@ -123,23 +122,6 @@ func buildTransport(opts ClientOpts, flags *Flags) http.RoundTripper { return rt } -// envProxyURL resolves the proxy URL -// from standard HTTP_PROXY/HTTPS_PROXY/NO_PROXY -// environment variables for the given endpoint. -// Returns nil if the endpoint is not a valid URL, -// no proxy is configured, or the endpoint is excluded. -func envProxyURL(endpoint string) *url.URL { - u, err := url.Parse(endpoint) - if err != nil || u.Scheme == "" || u.Host == "" { - return nil - } - proxyURL, err := http.ProxyFromEnvironment(&http.Request{URL: u}) - if err != nil || proxyURL == nil { - return nil - } - return proxyURL -} - // NewClient creates a new API client. func NewClient(opts ClientOpts) Client { if opts.Out == nil { From 5dae35479f7db6857a2fa294dedf4369a0ec5f45 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Fri, 27 Feb 2026 11:28:58 -0800 Subject: [PATCH 18/33] fix proxyDialAddr and add more tests for it --- internal/api/proxy.go | 14 +++++++------- internal/api/proxy_test.go | 11 ++++++++++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/internal/api/proxy.go b/internal/api/proxy.go index 1293406c0b..3f53328f29 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -24,14 +24,14 @@ func (c *connWithBufferedReader) Read(p []byte) (int, error) { // proxyDialAddr returns proxyURL.Host with a default port appended if one is // not already present (443 for https, 80 for http). func proxyDialAddr(proxyURL *url.URL) string { - addr := proxyURL.Host - if _, _, err := net.SplitHostPort(addr); err != nil { - if proxyURL.Scheme == "https" { - return net.JoinHostPort(addr, "443") - } - return net.JoinHostPort(addr, "80") + // net.SplitHostPort returns an error when the input doesn't contain a port + if _, _, err := net.SplitHostPort(proxyURL.Host); err == nil { + return proxyURL.Host + } + if proxyURL.Scheme == "https" { + return net.JoinHostPort(proxyURL.Hostname(), "443") } - return addr + return net.JoinHostPort(proxyURL.Hostname(), "80") } // withProxyTransport modifies the given transport to handle proxying of unix, socks5 and http connections. diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go index 8a243f4e9f..d1d9ee6348 100644 --- a/internal/api/proxy_test.go +++ b/internal/api/proxy_test.go @@ -416,10 +416,19 @@ func TestProxyDialAddr(t *testing.T) { {"https without port", "https://proxy.example.com", "proxy.example.com:443"}, {"http with port", "http://proxy.example.com:8080", "proxy.example.com:8080"}, {"http without port", "http://proxy.example.com", "proxy.example.com:80"}, + {"ipv4 with port", "http://192.168.1.100:3128", "192.168.1.100:3128"}, + {"ipv4 without port https", "https://10.0.0.1", "10.0.0.1:443"}, + {"ipv4 without port http", "http://172.16.0.5", "172.16.0.5:80"}, + {"ipv6 with port", "http://[::1]:8080", "[::1]:8080"}, + {"ipv6 without port https", "https://[2001:db8::1]", "[2001:db8::1]:443"}, + {"ipv6 without port http", "http://[fe80::1]", "[fe80::1]:80"}, + {"localhost with port", "http://localhost:9090", "localhost:9090"}, + {"localhost without port https", "https://localhost", "localhost:443"}, + {"localhost without port http", "http://localhost", "localhost:80"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - u, err := url.Parse(tt.url) + u, err := url.ParseRequestURI(tt.url) if err != nil { t.Fatalf("parse URL: %v", err) } From 651d4e9a60339a05145983a3db54a969c4832411 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Fri, 27 Feb 2026 11:29:52 -0800 Subject: [PATCH 19/33] add EndpointURL to the tests and other places that should use it instead of Endpoint --- cmd/src/search_jobs.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/src/search_jobs.go b/cmd/src/search_jobs.go index 15bf5d8a25..1bf033e1f3 100644 --- a/cmd/src/search_jobs.go +++ b/cmd/src/search_jobs.go @@ -259,11 +259,11 @@ func init() { usage := `'src search-jobs' is a tool that manages search jobs on a Sourcegraph instance. Usage: - + src search-jobs command [command options] - + The commands are: - + cancel cancels a search job by ID create creates a search job delete deletes a search job by ID @@ -272,11 +272,11 @@ func init() { logs fetches logs for a search job by ID restart restarts a search job by ID results fetches results for a search job by ID - + Common options for all commands: -c Select columns to display (e.g., -c id,query,state,username) -json Output results in JSON format - + Use "src search-jobs [command] -h" for more information about a command. ` From b82148f8babe8e80feae032bf46ae8a1b529eaba Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Fri, 27 Feb 2026 11:31:09 -0800 Subject: [PATCH 20/33] use the client to connect to the API instead of http.DefaultClient so that proxies are used --- cmd/src/search_jobs_logs.go | 6 +++--- cmd/src/search_jobs_results.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/src/search_jobs_logs.go b/cmd/src/search_jobs_logs.go index 6327a609ab..a32350fcaf 100644 --- a/cmd/src/search_jobs_logs.go +++ b/cmd/src/search_jobs_logs.go @@ -12,7 +12,7 @@ import ( ) // fetchJobLogs retrieves logs for a search job from its log URL -func fetchJobLogs(jobID string, logURL string) (io.ReadCloser, error) { +func fetchJobLogs(client api.Client, jobID string, logURL string) (io.ReadCloser, error) { if logURL == "" { return nil, fmt.Errorf("no logs URL found for search job %s", jobID) } @@ -24,7 +24,7 @@ func fetchJobLogs(jobID string, logURL string) (io.ReadCloser, error) { req.Header.Add("Authorization", "token "+cfg.accessToken) - resp, err := http.DefaultClient.Do(req) + resp, err := client.Do(req) if err != nil { return nil, err } @@ -88,7 +88,7 @@ func init() { return fmt.Errorf("no job found with ID %s", jobID) } - logsData, err := fetchJobLogs(jobID, job.LogURL) + logsData, err := fetchJobLogs(client, jobID, job.LogURL) if err != nil { return err } diff --git a/cmd/src/search_jobs_results.go b/cmd/src/search_jobs_results.go index 9d8bc7a9ab..1870387048 100644 --- a/cmd/src/search_jobs_results.go +++ b/cmd/src/search_jobs_results.go @@ -12,7 +12,7 @@ import ( ) // fetchJobResults retrieves results for a search job from its results URL -func fetchJobResults(jobID string, resultsURL string) (io.ReadCloser, error) { +func fetchJobResults(client api.Client, jobID string, resultsURL string) (io.ReadCloser, error) { if resultsURL == "" { return nil, fmt.Errorf("no results URL found for search job %s", jobID) } @@ -24,7 +24,7 @@ func fetchJobResults(jobID string, resultsURL string) (io.ReadCloser, error) { req.Header.Add("Authorization", "token "+cfg.accessToken) - resp, err := http.DefaultClient.Do(req) + resp, err := client.Do(req) if err != nil { return nil, err } @@ -90,7 +90,7 @@ func init() { return fmt.Errorf("no job found with ID %s", jobID) } - resultsData, err := fetchJobResults(jobID, job.URL) + resultsData, err := fetchJobResults(client, jobID, job.URL) if err != nil { return err } From 024fba3d6275e641e86e6d76307f2602bbe56cf1 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sat, 7 Mar 2026 19:52:39 -0800 Subject: [PATCH 21/33] restore unintentional whitespace changes --- cmd/src/search_jobs.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/src/search_jobs.go b/cmd/src/search_jobs.go index 1bf033e1f3..15bf5d8a25 100644 --- a/cmd/src/search_jobs.go +++ b/cmd/src/search_jobs.go @@ -259,11 +259,11 @@ func init() { usage := `'src search-jobs' is a tool that manages search jobs on a Sourcegraph instance. Usage: - + src search-jobs command [command options] - + The commands are: - + cancel cancels a search job by ID create creates a search job delete deletes a search job by ID @@ -272,11 +272,11 @@ func init() { logs fetches logs for a search job by ID restart restarts a search job by ID results fetches results for a search job by ID - + Common options for all commands: -c Select columns to display (e.g., -c id,query,state,username) -json Output results in JSON format - + Use "src search-jobs [command] -h" for more information about a command. ` From e29397bfdbb82f86a8e04eacb075bf8d9a002795 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sat, 7 Mar 2026 20:18:32 -0800 Subject: [PATCH 22/33] undo changes to search_jobs - do those changes in another PR --- cmd/src/search_jobs_logs.go | 6 +++--- cmd/src/search_jobs_results.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/src/search_jobs_logs.go b/cmd/src/search_jobs_logs.go index a32350fcaf..6327a609ab 100644 --- a/cmd/src/search_jobs_logs.go +++ b/cmd/src/search_jobs_logs.go @@ -12,7 +12,7 @@ import ( ) // fetchJobLogs retrieves logs for a search job from its log URL -func fetchJobLogs(client api.Client, jobID string, logURL string) (io.ReadCloser, error) { +func fetchJobLogs(jobID string, logURL string) (io.ReadCloser, error) { if logURL == "" { return nil, fmt.Errorf("no logs URL found for search job %s", jobID) } @@ -24,7 +24,7 @@ func fetchJobLogs(client api.Client, jobID string, logURL string) (io.ReadCloser req.Header.Add("Authorization", "token "+cfg.accessToken) - resp, err := client.Do(req) + resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } @@ -88,7 +88,7 @@ func init() { return fmt.Errorf("no job found with ID %s", jobID) } - logsData, err := fetchJobLogs(client, jobID, job.LogURL) + logsData, err := fetchJobLogs(jobID, job.LogURL) if err != nil { return err } diff --git a/cmd/src/search_jobs_results.go b/cmd/src/search_jobs_results.go index 1870387048..9d8bc7a9ab 100644 --- a/cmd/src/search_jobs_results.go +++ b/cmd/src/search_jobs_results.go @@ -12,7 +12,7 @@ import ( ) // fetchJobResults retrieves results for a search job from its results URL -func fetchJobResults(client api.Client, jobID string, resultsURL string) (io.ReadCloser, error) { +func fetchJobResults(jobID string, resultsURL string) (io.ReadCloser, error) { if resultsURL == "" { return nil, fmt.Errorf("no results URL found for search job %s", jobID) } @@ -24,7 +24,7 @@ func fetchJobResults(client api.Client, jobID string, resultsURL string) (io.Rea req.Header.Add("Authorization", "token "+cfg.accessToken) - resp, err := client.Do(req) + resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } @@ -90,7 +90,7 @@ func init() { return fmt.Errorf("no job found with ID %s", jobID) } - resultsData, err := fetchJobResults(client, jobID, job.URL) + resultsData, err := fetchJobResults(jobID, job.URL) if err != nil { return err } From 8d78585611fc99aa7e5b8bc99161325a0684a057 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sun, 8 Mar 2026 18:13:43 -0700 Subject: [PATCH 23/33] clarify comments and direct all proxy usage to the custom dialer --- internal/api/api.go | 12 ++++++------ internal/api/proxy.go | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 4b51801954..f3dbc703a3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -100,12 +100,12 @@ func buildTransport(opts ClientOpts, flags *Flags) http.RoundTripper { transport.TLSClientConfig = &tls.Config{} } - if opts.ProxyPath != "" || (opts.ProxyURL != nil && opts.ProxyURL.Scheme == "https") { - // Use our custom dialer for: - // - unix socket proxies - // - TLS=enabled proxies, to force HTTP/1.1 for the CONNECT tunnel. - // Many TLS-enabled proxy servers don't support HTTP/2 CONNECT, - // which Go may negotiate via ALPN, resulting in connection errors. + if opts.ProxyPath != "" || opts.ProxyURL != nil { + // Use our custom dialer for proxied connections. + // A custom dialer is not always needed - the connection libraries will handle HTTP(S)_PROXY-defined proxies + // (Go supports http, https, socks5, and socks5h proxies via HTTP(S)_PROXY), + // but we're also supporting proxies defined via SRC_PROXY, which can include UDS proxies, + // and connecting to TLS-enabled proxies adds an additional wrinkle when using HTTP/2. transport = withProxyTransport(transport, opts.ProxyURL, opts.ProxyPath) } diff --git a/internal/api/proxy.go b/internal/api/proxy.go index 3f53328f29..429e0ce5a9 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -82,10 +82,10 @@ func withProxyTransport(baseTransport *http.Transport, proxyURL *url.URL, proxyP baseTransport.Proxy = nil } else if proxyURL != nil { switch proxyURL.Scheme { - case "socks5", "socks5h": - // SOCKS proxies work out of the box - no need to manually dial + case "http", "socks5", "socks5h": + // HTTP and SOCKS proxies work out of the box - no need to manually dial baseTransport.Proxy = http.ProxyURL(proxyURL) - case "http", "https": + case "https": dial := func(ctx context.Context, network, addr string) (net.Conn, error) { // Dial the proxy. For https:// proxies, we TLS-connect to the // proxy itself and force ALPN to HTTP/1.1 to prevent Go from @@ -166,7 +166,7 @@ func withProxyTransport(baseTransport *http.Transport, proxyURL *url.URL, proxyP } baseTransport.DialContext = dial baseTransport.DialTLSContext = dialTLS - // clear out any system proxy settings + // clear out the system proxy because we're defining our own dialers baseTransport.Proxy = nil } } From 0c25a830502379c9396a4cffd1e4ccb2d8a70a41 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sun, 8 Mar 2026 18:34:10 -0700 Subject: [PATCH 24/33] add a mutex for the buffered reader to match the concurency behavior of net.Conn. --- internal/api/proxy.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/api/proxy.go b/internal/api/proxy.go index 429e0ce5a9..db1ce36e36 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -10,14 +10,18 @@ import ( "net" "net/http" "net/url" + "sync" ) type connWithBufferedReader struct { net.Conn - r *bufio.Reader + r *bufio.Reader + mu sync.Mutex } func (c *connWithBufferedReader) Read(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() return c.r.Read(p) } From d7bbb2174a4e0b174e348c5839a5b326d223001d Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sun, 8 Mar 2026 19:29:09 -0700 Subject: [PATCH 25/33] close the connection if the TLS handshake errors --- internal/api/proxy.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/api/proxy.go b/internal/api/proxy.go index db1ce36e36..8d4e657e9f 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -63,6 +63,7 @@ func withProxyTransport(baseTransport *http.Transport, proxyURL *url.URL, proxyP } tlsConn := tls.Client(conn, cfg) if err := tlsConn.HandshakeContext(ctx); err != nil { + tlsConn.Close() return nil, err } return tlsConn, nil From 210862b9b85d71fa2e75c103ee103b1994a943a6 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sun, 8 Mar 2026 20:48:25 -0700 Subject: [PATCH 26/33] refactor to use httptest package --- internal/api/proxy_test.go | 152 ++++++------------------------------- 1 file changed, 24 insertions(+), 128 deletions(-) diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go index d1d9ee6348..b1a7a57ea2 100644 --- a/internal/api/proxy_test.go +++ b/internal/api/proxy_test.go @@ -1,17 +1,10 @@ package api import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" "encoding/base64" "fmt" "io" - "math/big" "net" "net/http" "net/http/httptest" @@ -21,38 +14,6 @@ import ( "time" ) -// waitForServerReady polls the server until it's ready to accept connections -func waitForServerReady(t *testing.T, addr string, useTLS bool, timeout time.Duration) { - t.Helper() - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - for { - select { - case <-ctx.Done(): - t.Fatalf("server at %s did not become ready within %v", addr, timeout) - default: - } - - var conn net.Conn - var err error - - if useTLS { - conn, err = tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true}) - } else { - conn, err = net.Dial("tcp", addr) - } - - if err == nil { - conn.Close() - return // Server is ready - } - - time.Sleep(1 * time.Millisecond) - } -} - // startCONNECTProxy starts an HTTP or HTTPS CONNECT proxy on a random port. // It returns the proxy URL and a channel that receives the protocol observed by // the proxy handler for each CONNECT request. @@ -61,7 +22,7 @@ func startCONNECTProxy(t *testing.T, useTLS bool) (proxyURL *url.URL, obsCh <-ch ch := make(chan string, 10) - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case ch <- r.Proto: default: @@ -96,32 +57,16 @@ func startCONNECTProxy(t *testing.T, useTLS bool) (proxyURL *url.URL, obsCh <-ch go func() { io.Copy(destConn, clientConn); done <- struct{}{} }() go func() { io.Copy(clientConn, destConn); done <- struct{}{} }() <-done - }) - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("proxy listen: %v", err) - } - - srv := &http.Server{Handler: handler} + })) if useTLS { - cert := generateTestCert(t, "127.0.0.1") - srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} - go srv.ServeTLS(ln, "", "") + srv.StartTLS() } else { - go srv.Serve(ln) + srv.Start() } - t.Cleanup(func() { srv.Close() }) - - // Wait for the server to be ready - waitForServerReady(t, ln.Addr().String(), useTLS, 5*time.Second) + t.Cleanup(srv.Close) - scheme := "http" - if useTLS { - scheme = "https" - } - pURL, _ := url.Parse(fmt.Sprintf("%s://%s", scheme, ln.Addr().String())) + pURL, _ := url.Parse(srv.URL) return pURL, ch } @@ -130,7 +75,7 @@ func startCONNECTProxy(t *testing.T, useTLS bool) (proxyURL *url.URL, obsCh <-ch func startCONNECTProxyWithAuth(t *testing.T, useTLS bool, wantUser, wantPass string) (proxyURL *url.URL) { t.Helper() - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodConnect { http.Error(w, "expected CONNECT", http.StatusMethodNotAllowed) return @@ -167,61 +112,20 @@ func startCONNECTProxyWithAuth(t *testing.T, useTLS bool, wantUser, wantPass str go func() { io.Copy(destConn, clientConn); done <- struct{}{} }() go func() { io.Copy(clientConn, destConn); done <- struct{}{} }() <-done - }) - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("proxy listen: %v", err) - } - - srv := &http.Server{Handler: handler} + })) if useTLS { - cert := generateTestCert(t, "127.0.0.1") - srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} - go srv.ServeTLS(ln, "", "") + srv.StartTLS() } else { - go srv.Serve(ln) + srv.Start() } - t.Cleanup(func() { srv.Close() }) - - // Wait for the server to be ready - waitForServerReady(t, ln.Addr().String(), useTLS, 5*time.Second) + t.Cleanup(srv.Close) - scheme := "http" - if useTLS { - scheme = "https" - } - pURL, _ := url.Parse(fmt.Sprintf("%s://%s@%s", scheme, url.UserPassword(wantUser, wantPass).String(), ln.Addr().String())) + pURL, _ := url.Parse(srv.URL) + pURL.User = url.UserPassword(wantUser, wantPass) return pURL } -func generateTestCert(t *testing.T, host string) tls.Certificate { - t.Helper() - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatalf("generate key: %v", err) - } - template := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: host}, - NotBefore: time.Now(), - NotAfter: time.Now().Add(1 * time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - IPAddresses: []net.IP{net.ParseIP(host)}, - } - certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) - if err != nil { - t.Fatalf("create cert: %v", err) - } - return tls.Certificate{ - Certificate: [][]byte{certDER}, - PrivateKey: key, - } -} - // newTestTransport creates a base transport suitable for proxy tests. func newTestTransport() *http.Transport { transport := http.DefaultTransport.(*http.Transport).Clone() @@ -368,39 +272,31 @@ func TestWithProxyTransport_ProxyRejectsConnect(t *testing.T) { name string statusCode int body string - wantStatus string + wantErr string }{ - {"407 proxy auth required", http.StatusProxyAuthRequired, "proxy auth required", "407 Proxy Authentication Required"}, - {"403 forbidden", http.StatusForbidden, "access denied by policy", "403 Forbidden"}, - {"502 bad gateway", http.StatusBadGateway, "upstream unreachable", "502 Bad Gateway"}, + {"407 proxy auth required", http.StatusProxyAuthRequired, "proxy auth required", "Proxy Authentication Required"}, + {"403 forbidden", http.StatusForbidden, "access denied by policy", "Forbidden"}, + {"502 bad gateway", http.StatusBadGateway, "upstream unreachable", "Bad Gateway"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Start a proxy that always rejects CONNECT with the given status. - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } - srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, tt.body, tt.statusCode) - })} - go srv.Serve(ln) - t.Cleanup(func() { srv.Close() }) + })) + t.Cleanup(srv.Close) - proxyURL, _ := url.Parse(fmt.Sprintf("http://%s", ln.Addr().String())) + proxyURL, _ := url.Parse(srv.URL) transport := withProxyTransport(newTestTransport(), proxyURL, "") client := &http.Client{Transport: transport, Timeout: 10 * time.Second} - _, err = client.Get("https://example.com") + _, err := client.Get("https://example.com") if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), tt.wantStatus) { - t.Errorf("error should contain status %q, got: %v", tt.wantStatus, err) - } - if !strings.Contains(err.Error(), tt.body) { - t.Errorf("error should contain body %q, got: %v", tt.body, err) + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error should contain %q, got: %v", tt.wantErr, err) } }) } From 75d0aee86425c9e92367a6e268d3df408c257459 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sun, 8 Mar 2026 21:11:02 -0700 Subject: [PATCH 27/33] rename functions and address possible leaks --- internal/api/proxy_test.go | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go index b1a7a57ea2..97ae168eb3 100644 --- a/internal/api/proxy_test.go +++ b/internal/api/proxy_test.go @@ -14,10 +14,10 @@ import ( "time" ) -// startCONNECTProxy starts an HTTP or HTTPS CONNECT proxy on a random port. +// startProxy starts an HTTP or HTTPS CONNECT proxy on a random port. // It returns the proxy URL and a channel that receives the protocol observed by // the proxy handler for each CONNECT request. -func startCONNECTProxy(t *testing.T, useTLS bool) (proxyURL *url.URL, obsCh <-chan string) { +func startProxy(t *testing.T, useTLS bool) (proxyURL *url.URL, obsCh <-chan string) { t.Helper() ch := make(chan string, 10) @@ -57,6 +57,10 @@ func startCONNECTProxy(t *testing.T, useTLS bool) (proxyURL *url.URL, obsCh <-ch go func() { io.Copy(destConn, clientConn); done <- struct{}{} }() go func() { io.Copy(clientConn, destConn); done <- struct{}{} }() <-done + // Close both sides so the remaining goroutine unblocks. + clientConn.Close() + destConn.Close() + <-done })) if useTLS { @@ -70,9 +74,9 @@ func startCONNECTProxy(t *testing.T, useTLS bool) (proxyURL *url.URL, obsCh <-ch return pURL, ch } -// startCONNECTProxyWithAuth is like startCONNECTProxy but requires +// startProxyWithAuth is like startProxy but requires // Proxy-Authorization with the given username and password. -func startCONNECTProxyWithAuth(t *testing.T, useTLS bool, wantUser, wantPass string) (proxyURL *url.URL) { +func startProxyWithAuth(t *testing.T, useTLS bool, wantUser, wantPass string) (proxyURL *url.URL) { t.Helper() srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -112,6 +116,9 @@ func startCONNECTProxyWithAuth(t *testing.T, useTLS bool, wantUser, wantPass str go func() { io.Copy(destConn, clientConn); done <- struct{}{} }() go func() { io.Copy(clientConn, destConn); done <- struct{}{} }() <-done + clientConn.Close() + destConn.Close() + <-done })) if useTLS { @@ -148,9 +155,10 @@ func startTargetServer(t *testing.T) *httptest.Server { func TestWithProxyTransport_HTTPProxy(t *testing.T) { target := startTargetServer(t) - proxyURL, obsCh := startCONNECTProxy(t, false) + proxyURL, obsCh := startProxy(t, false) transport := withProxyTransport(newTestTransport(), proxyURL, "") + t.Cleanup(transport.CloseIdleConnections) client := &http.Client{Transport: transport, Timeout: 10 * time.Second} resp, err := client.Get(target.URL) @@ -179,9 +187,10 @@ func TestWithProxyTransport_HTTPProxy(t *testing.T) { func TestWithProxyTransport_HTTPSProxy(t *testing.T) { target := startTargetServer(t) - proxyURL, obsCh := startCONNECTProxy(t, true) + proxyURL, obsCh := startProxy(t, true) transport := withProxyTransport(newTestTransport(), proxyURL, "") + t.Cleanup(transport.CloseIdleConnections) client := &http.Client{Transport: transport, Timeout: 10 * time.Second} resp, err := client.Get(target.URL) @@ -212,8 +221,9 @@ func TestWithProxyTransport_ProxyAuth(t *testing.T) { target := startTargetServer(t) t.Run("http proxy with auth", func(t *testing.T) { - proxyURL := startCONNECTProxyWithAuth(t, false, "user", "pass") + proxyURL := startProxyWithAuth(t, false, "user", "pass") transport := withProxyTransport(newTestTransport(), proxyURL, "") + t.Cleanup(transport.CloseIdleConnections) client := &http.Client{Transport: transport, Timeout: 10 * time.Second} resp, err := client.Get(target.URL) @@ -229,8 +239,9 @@ func TestWithProxyTransport_ProxyAuth(t *testing.T) { }) t.Run("https proxy with auth", func(t *testing.T) { - proxyURL := startCONNECTProxyWithAuth(t, true, "user", "s3cret") + proxyURL := startProxyWithAuth(t, true, "user", "s3cret") transport := withProxyTransport(newTestTransport(), proxyURL, "") + t.Cleanup(transport.CloseIdleConnections) client := &http.Client{Transport: transport, Timeout: 10 * time.Second} resp, err := client.Get(target.URL) @@ -250,9 +261,10 @@ func TestWithProxyTransport_HTTPSProxy_HTTP2ToOrigin(t *testing.T) { // Verify that when tunneling through an HTTPS proxy, the connection to // the origin target still negotiates HTTP/2 (not downgraded to HTTP/1.1). target := startTargetServer(t) - proxyURL, _ := startCONNECTProxy(t, true) + proxyURL, _ := startProxy(t, true) transport := withProxyTransport(newTestTransport(), proxyURL, "") + t.Cleanup(transport.CloseIdleConnections) client := &http.Client{Transport: transport, Timeout: 10 * time.Second} resp, err := client.Get(target.URL) @@ -289,6 +301,7 @@ func TestWithProxyTransport_ProxyRejectsConnect(t *testing.T) { proxyURL, _ := url.Parse(srv.URL) transport := withProxyTransport(newTestTransport(), proxyURL, "") + t.Cleanup(transport.CloseIdleConnections) client := &http.Client{Transport: transport, Timeout: 10 * time.Second} _, err := client.Get("https://example.com") From f810d2aba6b50624ab065abf27e71d1d3428b1bc Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sun, 8 Mar 2026 21:13:37 -0700 Subject: [PATCH 28/33] check I/O errors; use resp.ProtoMajor instead of strings --- internal/api/proxy_test.go | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go index 97ae168eb3..e398246476 100644 --- a/internal/api/proxy_test.go +++ b/internal/api/proxy_test.go @@ -166,7 +166,10 @@ func TestWithProxyTransport_HTTPProxy(t *testing.T) { t.Fatalf("GET through http proxy: %v", err) } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) @@ -198,7 +201,10 @@ func TestWithProxyTransport_HTTPSProxy(t *testing.T) { t.Fatalf("GET through https proxy: %v", err) } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) @@ -231,7 +237,9 @@ func TestWithProxyTransport_ProxyAuth(t *testing.T) { t.Fatalf("GET through authenticated http proxy: %v", err) } defer resp.Body.Close() - io.ReadAll(resp.Body) + if _, err := io.ReadAll(resp.Body); err != nil { + t.Fatalf("read body: %v", err) + } if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) @@ -249,7 +257,9 @@ func TestWithProxyTransport_ProxyAuth(t *testing.T) { t.Fatalf("GET through authenticated https proxy: %v", err) } defer resp.Body.Close() - io.ReadAll(resp.Body) + if _, err := io.ReadAll(resp.Body); err != nil { + t.Fatalf("read body: %v", err) + } if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) @@ -272,10 +282,12 @@ func TestWithProxyTransport_HTTPSProxy_HTTP2ToOrigin(t *testing.T) { t.Fatalf("GET through https proxy: %v", err) } defer resp.Body.Close() - io.ReadAll(resp.Body) + if _, err := io.ReadAll(resp.Body); err != nil { + t.Fatalf("read body: %v", err) + } - if resp.Proto != "HTTP/2.0" { - t.Errorf("expected HTTP/2.0 to origin, got %s", resp.Proto) + if resp.ProtoMajor != 2 { + t.Errorf("expected HTTP/2 to origin, got %s", resp.Proto) } } From a13b998438906846b2544c4e0fd228734ce884ee Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sun, 8 Mar 2026 21:20:04 -0700 Subject: [PATCH 29/33] add test to confirm that closing the connection on handshake error (`tlsConn.Close()`) is necessary --- internal/api/proxy_test.go | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go index e398246476..6aed141f4d 100644 --- a/internal/api/proxy_test.go +++ b/internal/api/proxy_test.go @@ -291,6 +291,53 @@ func TestWithProxyTransport_HTTPSProxy_HTTP2ToOrigin(t *testing.T) { } } +func TestWithProxyTransport_HandshakeFailureClosesConn(t *testing.T) { + // Verify that when the TLS handshake to the origin fails, the underlying + // tunnel connection is closed (regression test for tlsConn.Close on error). + // + // A plain TCP listener acts as the target. The proxy CONNECT succeeds + // (TCP-level), but the subsequent TLS handshake fails because the target + // is not a TLS server. If handshakeTLS properly closes tlsConn on failure, + // the tunnel tears down and the target sees the connection close. + connClosed := make(chan struct{}) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + // Send non-TLS bytes so the client handshake fails immediately + // rather than waiting for a timeout. + conn.Write([]byte("not-tls\n")) + // Drain until the remote side closes the tunnel. + io.Copy(io.Discard, conn) + close(connClosed) + }() + + proxyURL, _ := startProxy(t, true) + transport := withProxyTransport(newTestTransport(), proxyURL, "") + t.Cleanup(transport.CloseIdleConnections) + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + + _, err = client.Get("https://" + ln.Addr().String()) + if err == nil { + t.Fatal("expected TLS handshake error, got nil") + } + + select { + case <-connClosed: + // Connection was properly cleaned up. + case <-time.After(5 * time.Second): + t.Fatal("connection was not closed after TLS handshake failure") + } +} + func TestWithProxyTransport_ProxyRejectsConnect(t *testing.T) { tests := []struct { name string From f6c4e55c168f4e7183d702d317850603781a01d7 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sun, 8 Mar 2026 22:23:58 -0700 Subject: [PATCH 30/33] fmt.Errorf --> errors.Newf --- internal/api/proxy.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/api/proxy.go b/internal/api/proxy.go index 8d4e657e9f..605537f311 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -5,12 +5,13 @@ import ( "context" "crypto/tls" "encoding/base64" - "fmt" "io" "net" "net/http" "net/url" "sync" + + "github.com/sourcegraph/sourcegraph/lib/errors" ) type connWithBufferedReader struct { @@ -156,7 +157,7 @@ func withProxyTransport(baseTransport *http.Transport, proxyURL *url.URL, proxyP b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) resp.Body.Close() conn.Close() - return nil, fmt.Errorf("failed to connect to proxy %s: %s: %q", proxyURL.Redacted(), resp.Status, b) + return nil, errors.Newf("failed to connect to proxy %s: %s: %q", proxyURL.Redacted(), resp.Status, b) } // 200 CONNECT: do NOT resp.Body.Close(); it would interfere with the tunnel. return &connWithBufferedReader{Conn: conn, r: br}, nil From 681f49836b19a9e5f0e2ffebde93ca40ab49f5d8 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sun, 8 Mar 2026 22:34:21 -0700 Subject: [PATCH 31/33] add test for https connection rejection - ensure correct error handling in the custom dialer --- internal/api/proxy_test.go | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go index 6aed141f4d..06cd511bdb 100644 --- a/internal/api/proxy_test.go +++ b/internal/api/proxy_test.go @@ -350,9 +350,11 @@ func TestWithProxyTransport_ProxyRejectsConnect(t *testing.T) { {"502 bad gateway", http.StatusBadGateway, "upstream unreachable", "Bad Gateway"}, } + // Use a local target so we never depend on external DNS. + target := startTargetServer(t) + for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Start a proxy that always rejects CONNECT with the given status. + t.Run("http proxy/"+tt.name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, tt.body, tt.statusCode) })) @@ -363,7 +365,7 @@ func TestWithProxyTransport_ProxyRejectsConnect(t *testing.T) { t.Cleanup(transport.CloseIdleConnections) client := &http.Client{Transport: transport, Timeout: 10 * time.Second} - _, err := client.Get("https://example.com") + _, err := client.Get(target.URL) if err == nil { t.Fatal("expected error, got nil") } @@ -371,6 +373,32 @@ func TestWithProxyTransport_ProxyRejectsConnect(t *testing.T) { t.Errorf("error should contain %q, got: %v", tt.wantErr, err) } }) + + t.Run("https proxy/"+tt.name, func(t *testing.T) { + // The HTTPS proxy path uses a custom dialer with its own error + // formatting that includes the status, body, and redacted proxy URL. + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, tt.body, tt.statusCode) + })) + srv.StartTLS() + t.Cleanup(srv.Close) + + proxyURL, _ := url.Parse(srv.URL) + transport := withProxyTransport(newTestTransport(), proxyURL, "") + t.Cleanup(transport.CloseIdleConnections) + client := &http.Client{Transport: transport, Timeout: 10 * time.Second} + + _, err := client.Get(target.URL) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), fmt.Sprintf("%d", tt.statusCode)) { + t.Errorf("error should contain status code %d, got: %v", tt.statusCode, err) + } + if !strings.Contains(err.Error(), tt.body) { + t.Errorf("error should contain body %q, got: %v", tt.body, err) + } + }) } } From dff0820f846189bfa32933133b421db8bd065ebc Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Mon, 9 Mar 2026 19:45:39 -0700 Subject: [PATCH 32/33] use JoinPath in batch_remote --- cmd/src/batch_remote.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/src/batch_remote.go b/cmd/src/batch_remote.go index 86ad3650d1..c1d7209675 100644 --- a/cmd/src/batch_remote.go +++ b/cmd/src/batch_remote.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" cliLog "log" - "strings" "time" "github.com/sourcegraph/sourcegraph/lib/errors" @@ -155,13 +154,14 @@ Examples: } ui.ExecutingBatchSpecSuccess() - executionURL := fmt.Sprintf( - "%s/%s/batch-changes/%s/executions/%s", - cfg.endpointURL, - strings.TrimPrefix(namespace.URL, "/"), - batchChangeName, - batchSpecID, - ) + executionURL := cfg.endpointURL.JoinPath( + fmt.Sprintf( + "%s/batch-changes/%s/executions/%s", + namespace.URL, + batchChangeName, + batchSpecID, + ), + ).String() ui.RemoteSuccess(executionURL) return nil From 039c39f2e7540d51539b5d9b096463a382ae7a22 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Mon, 9 Mar 2026 20:18:46 -0700 Subject: [PATCH 33/33] add retry for test that is flaky on CI, probably due to resource contention --- internal/api/proxy_test.go | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go index 06cd511bdb..29b8f5345b 100644 --- a/internal/api/proxy_test.go +++ b/internal/api/proxy_test.go @@ -247,14 +247,25 @@ func TestWithProxyTransport_ProxyAuth(t *testing.T) { }) t.Run("https proxy with auth", func(t *testing.T) { - proxyURL := startProxyWithAuth(t, true, "user", "s3cret") - transport := withProxyTransport(newTestTransport(), proxyURL, "") - t.Cleanup(transport.CloseIdleConnections) - client := &http.Client{Transport: transport, Timeout: 10 * time.Second} - - resp, err := client.Get(target.URL) - if err != nil { - t.Fatalf("GET through authenticated https proxy: %v", err) + // Under the race detector on resource-constrained CI hosts + // the TLS handshake to the proxy can sporadically fail with + // "first record does not look like a TLS handshake" / EOF. + // Retry with a fresh proxy + transport to tolerate this. + var resp *http.Response + var lastErr error + for attempt := range 3 { + proxyURL := startProxyWithAuth(t, true, "user", "s3cret") + transport := withProxyTransport(newTestTransport(), proxyURL, "") + client := &http.Client{Transport: transport, Timeout: 10 * time.Second} + resp, lastErr = client.Get(target.URL) + transport.CloseIdleConnections() + if lastErr == nil { + break + } + t.Logf("attempt %d: %v", attempt+1, lastErr) + } + if lastErr != nil { + t.Fatalf("GET through authenticated https proxy (after retries): %v", lastErr) } defer resp.Body.Close() if _, err := io.ReadAll(resp.Body); err != nil {