Search code examples
goproxygrpchttp2grpc-go

How can I make a gRPC connection in Golang with an http proxy?


I am trying to connect to a gRPC server running with TLS, on port :443 through an HTTP proxy. I've checked that the HTTP proxy supports HTTP/2, but I can't seem to find a valid CustomDialer to use a proxy. The documentation states that setting environment variables sets a proxy, but this doesn't work for my case since I'm trying to connect to the same server multiple times using different proxies. docs

I've tried coding a custom dialer, but I just don't even know where to start. I can't find any examples of a custom dialer and am not knowledgeable enough to write one from scratch.


Solution

  • The new way to provide a custom dialer is using the WithContextDialer funcion. It takes a function that returns a net.Conn for a given address string.

    The default implementation of a dialer that uses a HTTP connect proxy is in the proxyDial function. It connects to the proxy server and returns the connection.

    Luckily, the only part that you need to change in the default implementation is the mapAddress function that reads the proxy URL from the environment variable. You'll want to instead put your logic to determine the required proxy server. You can copy the remaining code and it should work. It should look something like below:

    import (
        "bufio"
        "context"
        "encoding/base64"
        "fmt"
        "io"
        "net"
        "net/http"
        "net/url"
        "syscall"
        "time"
    
        "golang.org/x/sys/unix"
        "google.golang.org/grpc"
    )
    
    func proxyURLForAddr(addr string) url.URL {
        // Write your code to get the proxy URL.
        return url.URL{
            Scheme: "https",
            Host:   "www.example.com",
            Path:   "/path/to/resource",
        }
    }
    
    type bufConn struct {
        net.Conn
        r io.Reader
    }
    
    func (c *bufConn) Read(b []byte) (int, error) {
        return c.r.Read(b)
    }
    
    func netDialerWithTCPKeepalive() *net.Dialer {
        return &net.Dialer{
            // Setting a negative value here prevents the Go stdlib from overriding
            // the values of TCP keepalive time and interval. It also prevents the
            // Go stdlib from enabling TCP keepalives by default.
            KeepAlive: time.Duration(-1),
            // This method is called after the underlying network socket is created,
            // but before dialing the socket (or calling its connect() method). The
            // combination of unconditionally enabling TCP keepalives here, and
            // disabling the overriding of TCP keepalive parameters by setting the
            // KeepAlive field to a negative value above, results in OS defaults for
            // the TCP keealive interval and time parameters.
            Control: func(_, _ string, c syscall.RawConn) error {
                return c.Control(func(fd uintptr) {
                    unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_KEEPALIVE, 1)
                })
            },
        }
    }
    
    func basicAuth(username, password string) string {
        auth := username + ":" + password
        return base64.StdEncoding.EncodeToString([]byte(auth))
    }
    
    func sendHTTPRequest(ctx context.Context, req *http.Request, conn net.Conn) error {
        req = req.WithContext(ctx)
        if err := req.Write(conn); err != nil {
            return fmt.Errorf("failed to write the HTTP request: %v", err)
        }
        return nil
    }
    
    func doHTTPConnectHandshake(ctx context.Context, conn net.Conn, backendAddr string, proxyURL url.URL) (_ net.Conn, err error) {
        defer func() {
            if err != nil {
                conn.Close()
            }
        }()
    
        req := &http.Request{
            Method: http.MethodConnect,
            URL:    &url.URL{Host: backendAddr},
        }
        if t := proxyURL.User; t != nil {
            u := t.Username()
            p, _ := t.Password()
            req.Header.Add("Proxy-Authorization", "Basic "+basicAuth(u, p))
        }
    
        if err := sendHTTPRequest(ctx, req, conn); err != nil {
            return nil, fmt.Errorf("failed to write the HTTP request: %v", err)
        }
    
        r := bufio.NewReader(conn)
        resp, err := http.ReadResponse(r, req)
        if err != nil {
            return nil, fmt.Errorf("reading server HTTP response: %v", err)
        }
        defer resp.Body.Close()
        if resp.StatusCode != http.StatusOK {
            return nil, fmt.Errorf("failed to do connect handshake, status code: %s", resp.Status)
        }
    
        return &bufConn{Conn: conn, r: r}, nil
    }
    
    func proxyDialer(ctx context.Context, addr string) (net.Conn, error) {
        proxyURL := proxyURLForAddr(addr)
        proxyAddr := proxyURL.Host
    
        conn, err := netDialerWithTCPKeepalive().DialContext(ctx, "tcp", proxyAddr)
        if err != nil {
            return nil, err
        }
        return doHTTPConnectHandshake(ctx, conn, addr, proxyURL)
    }
    

    You can then use the proxyDialer as grpc.WithContextDialer(proxyDialer).

    Note that I don't have a Http Connect Proxy server running right now, so I haven't tested this code.