Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3ac2124
fix formatting and error messages
peterguy Mar 7, 2026
b2f1e4e
add function to read HTTP_PROXY/HTTPS_PROXY/NO_PROXY environment vari…
peterguy Feb 25, 2026
a7a2f59
use new function to read proxy settings from environment, preferring …
peterguy Feb 25, 2026
e929425
use http.Request instead of manually building the request, and use Re…
peterguy Feb 25, 2026
9993da2
commit to a buffered reader for the proxy connection so we avoid nast…
peterguy Feb 25, 2026
4c2de90
clone the transport TLS config instead of creating a new one, and mak…
peterguy Feb 25, 2026
bae69a7
fix some comments
peterguy Feb 25, 2026
fb16c50
whoops, forgot to commit the io import
peterguy Feb 25, 2026
b3895b3
dial the proxy, ensuring http/1.1 instead of http/2 for TLS-enabled p…
peterguy Feb 25, 2026
69763d9
add proxy tests
peterguy Feb 25, 2026
6031e19
change name of test to match production method
peterguy Feb 25, 2026
8853c80
add 10ms delay to test proxy server startup to try to fix ubuntu tests
peterguy Feb 26, 2026
bd3b210
go-lint.sh
peterguy Feb 26, 2026
7016f06
wait for test proxy to startup
peterguy Feb 26, 2026
8a8032a
go-lint.sh
peterguy Feb 26, 2026
b4724eb
parse the endpoint into a URL up front, and gather the proxy from the…
peterguy Feb 27, 2026
50ea28b
use the parsed endpoint url and consolidate proxy handling because th…
peterguy Feb 27, 2026
5dae354
fix proxyDialAddr and add more tests for it
peterguy Feb 27, 2026
651d4e9
add EndpointURL to the tests and other places that should use it inst…
peterguy Feb 27, 2026
b82148f
use the client to connect to the API instead of http.DefaultClient so…
peterguy Feb 27, 2026
024fba3
restore unintentional whitespace changes
peterguy Mar 8, 2026
e29397b
undo changes to search_jobs - do those changes in another PR
peterguy Mar 8, 2026
8d78585
clarify comments and direct all proxy usage to the custom dialer
peterguy Mar 9, 2026
0c25a83
add a mutex for the buffered reader to match the concurency behavior …
peterguy Mar 9, 2026
d7bbb21
close the connection if the TLS handshake errors
peterguy Mar 9, 2026
210862b
refactor to use httptest package
peterguy Mar 9, 2026
75d0aee
rename functions and address possible leaks
peterguy Mar 9, 2026
f810d2a
check I/O errors; use resp.ProtoMajor instead of strings
peterguy Mar 9, 2026
a13b998
add test to confirm that closing the connection on handshake error (`…
peterguy Mar 9, 2026
f6c4e55
fmt.Errorf --> errors.Newf
peterguy Mar 9, 2026
681f498
add test for https connection rejection - ensure correct error handli…
peterguy Mar 9, 2026
dff0820
use JoinPath in batch_remote
peterguy Mar 10, 2026
039c39f
add retry for test that is flaky on CI, probably due to resource cont…
peterguy Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions cmd/src/batch_remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"flag"
"fmt"
cliLog "log"
"strings"
"time"

"github.com/sourcegraph/sourcegraph/lib/errors"
Expand Down Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions cmd/src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
Expand Down Expand Up @@ -169,7 +170,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 != ""
Expand Down Expand Up @@ -282,12 +284,20 @@ 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)
Copy link
Member

Choose a reason for hiding this comment

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

you changed this is some other places as well. go convention is lowercase on error messages since they get combined. eg My outer error: My inner error doesn't read as nicely as my outer error: my inner error

}
cfg.proxyPath = path
} 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()
Expand Down Expand Up @@ -319,7 +329,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()

Expand Down
37 changes: 20 additions & 17 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,33 +90,36 @@ 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.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)
}

// For http:// and socks5:// proxies, the cloned
// transport's default Proxy handles them correctly without intervention.
Comment on lines +112 to +113
Copy link
Member

Choose a reason for hiding this comment

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

if I set SRC_PROXY to a http:// or socks5:// though, won't the default transport ignore it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I re-worked this section


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
}

// NewClient creates a new API client.
Expand Down
146 changes: 96 additions & 50 deletions internal/api/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,66 @@ import (
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sync"

"github.com/sourcegraph/sourcegraph/lib/errors"
)

type connWithBufferedReader struct {
net.Conn
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)
}
Comment on lines +23 to +27
Copy link
Member

Choose a reason for hiding this comment

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

net.Conn documents all methods to be concurrency safe. However, bufio.Reader is not. You need to add in a mutex here.

> Multiple goroutines may invoke methods on a Conn simultaneously.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oh, thanks!


// 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 {
// 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 net.JoinHostPort(proxyURL.Hostname(), "80")
}

// 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()
//
// - 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
host, _, err := net.SplitHostPort(addr)
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 {
tlsConn.Close()
return nil, err
}
return tlsConn, nil
Expand All @@ -54,67 +88,79 @@ 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
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
}

// 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
}

// Read and check the response from the proxy
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
br := bufio.NewReader(conn)
Copy link
Member

Choose a reason for hiding this comment

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

It isn't clear to me why you introduced the buffered reader?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because I didn't want to lose any bytes after the headers that the reader may have buffered, which bytes could be the start of the tunneled connection data. Granted, it's probably not really a problem because the SG endpoint will in nearly every scenario also be TLS, and "TLS server will not speak until spoken to.", so there will not be any bytes beyond the header. But if the SG endpoint is plain http, we could lose bytes if we discard the buffered reader.

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, errors.Newf("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
Expand All @@ -126,7 +172,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
}
}
Expand Down
Loading
Loading