diff --git a/lib/dialer/internal.go b/lib/dialer/internal.go index a972e9684..0c2730127 100644 --- a/lib/dialer/internal.go +++ b/lib/dialer/internal.go @@ -7,11 +7,17 @@ package dialer import ( + "bufio" + "context" + "crypto/tls" + "encoding/base64" + "fmt" "log/slog" "net" "net/http" "net/url" "os" + "sync" "time" "golang.org/x/net/proxy" @@ -21,6 +27,8 @@ var noFallback = os.Getenv("ALL_PROXY_NO_FALLBACK") != "" func init() { proxy.RegisterDialerType("socks", socksDialerFunction) + proxy.RegisterDialerType("http", httpDialerFunction) + proxy.RegisterDialerType("https", httpDialerFunction) if proxyDialer := proxy.FromEnvironment(); proxyDialer != proxy.Direct { http.DefaultTransport = &http.Transport{ @@ -59,6 +67,114 @@ func socksDialerFunction(u *url.URL, forward proxy.Dialer) (proxy.Dialer, error) return proxy.SOCKS5("tcp", u.Host, auth, forward) } +type httpProxyDialer struct { + proxyURL *url.URL + forwardDialer proxy.Dialer +} + +func httpDialerFunction(u *url.URL, forward proxy.Dialer) (proxy.Dialer, error) { + return &httpProxyDialer{ + proxyURL: u, + forwardDialer: forward, + }, nil +} + +func (h *httpProxyDialer) Dial(network, addr string) (net.Conn, error) { + return h.DialContext(context.Background(), network, addr) +} + +// bufferedConn wraps a bufio.Reader (needed by http.ReadResponse) while +// providing the other net.Conn methods via embedding. +type bufferedConn struct { + net.Conn + + reader *bufio.Reader +} + +func (c *bufferedConn) Read(b []byte) (int, error) { + return c.reader.Read(b) +} + +var warnCleartextProxyAuthOnce sync.Once + +func (h *httpProxyDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + if network != "tcp" && network != "tcp4" && network != "tcp6" { + return nil, fmt.Errorf("unsupported network for http proxy: %s", network) + } + + var conn net.Conn + var err error + if cd, ok := h.forwardDialer.(proxy.ContextDialer); ok { + conn, err = cd.DialContext(ctx, "tcp", h.proxyURL.Host) + } else { + conn, err = h.forwardDialer.Dial("tcp", h.proxyURL.Host) + } + if err != nil { + return nil, fmt.Errorf("dial proxy %s: %w", h.proxyURL.Host, err) + } + + if deadline, ok := ctx.Deadline(); ok { + if err := conn.SetDeadline(deadline); err != nil { + conn.Close() + return nil, fmt.Errorf("set proxy connection deadline: %w", err) + } + defer func() { _ = conn.SetDeadline(time.Time{}) }() + } + + if h.proxyURL.Scheme == "https" { + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: h.proxyURL.Hostname(), + }) + if err := tlsConn.HandshakeContext(ctx); err != nil { + conn.Close() + return nil, fmt.Errorf("https proxy handshake: %w", err) + } + conn = tlsConn + } + + req := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Host: addr}, + Host: addr, + Header: make(http.Header), + } + req = req.WithContext(ctx) + + if u := h.proxyURL.User; u != nil { + if h.proxyURL.Scheme == "http" { + warnCleartextProxyAuthOnce.Do(func() { + slog.WarnContext(ctx, + "Using basic auth over cleartext HTTP proxy", + "proxy", h.proxyURL.Redacted(), + ) + }) + } + password, _ := u.Password() + auth := base64.StdEncoding.EncodeToString([]byte(u.Username() + ":" + password)) + req.Header.Set("Proxy-Authorization", "Basic "+auth) + } + + if err := req.Write(conn); err != nil { + conn.Close() + return nil, fmt.Errorf("write proxy CONNECT request: %w", err) + } + + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, req) + if err != nil { + conn.Close() + return nil, fmt.Errorf("read proxy CONNECT response: %w", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + conn.Close() + return nil, fmt.Errorf("http proxy CONNECT failed: %s", resp.Status) + } + + return &bufferedConn{Conn: conn, reader: br}, nil +} + // dialerConn is needed because proxy dialed connections have RemoteAddr() pointing at the proxy, // which then screws up various things such as IsLAN checks, and "let's populate the relay invitation address from // existing connection" shenanigans.