| 1 | package netclient |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "crypto/tls" |
| 7 | "fmt" |
| 8 | "net" |
| 9 | "net/http" |
| 10 | "net/url" |
| 11 | "strings" |
| 12 | "time" |
| 13 | |
| 14 | "golang.org/x/net/proxy" |
| 15 | ) |
| 16 | |
| 17 | // StreamDialer opens a raw TCP stream under the same proxy policy netclient |
| 18 | // applies to HTTP. It is the dial seam for non-HTTP protocols (SSH) so a |
| 19 | // user's configured proxy is honored consistently. |
| 20 | type StreamDialer interface { |
| 21 | DialContext(ctx context.Context, network, addr string) (net.Conn, error) |
| 22 | } |
| 23 | |
| 24 | // DialerFunc adapts a function to StreamDialer. |
| 25 | type DialerFunc func(ctx context.Context, network, addr string) (net.Conn, error) |
| 26 | |
| 27 | // DialContext implements StreamDialer. |
| 28 | func (f DialerFunc) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { |
| 29 | return f(ctx, network, addr) |
| 30 | } |
| 31 | |
| 32 | // NewStreamDialer builds a StreamDialer for spec. Off/env/auto with no |
| 33 | // applicable proxy dial directly; socks5/socks5h dial through the SOCKS proxy; |
| 34 | // http/https dial through an HTTP CONNECT tunnel. DirectHosts and NoProxy are |
| 35 | // honored via the shared proxy resolution. |
| 36 | func NewStreamDialer(spec ProxySpec) (StreamDialer, error) { |
| 37 | pf, err := proxyFunc(spec) |
| 38 | if err != nil { |
| 39 | return nil, err |
| 40 | } |
| 41 | base := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second} |
| 42 | if pf == nil { |
| 43 | return DialerFunc(base.DialContext), nil |
| 44 | } |
| 45 | return DialerFunc(func(ctx context.Context, network, addr string) (net.Conn, error) { |
| 46 | // proxyFunc keys off a request URL; synthesize one for the target so |
| 47 | // scheme-agnostic TCP dials reuse the exact NoProxy/DirectHosts logic. |
| 48 | host, _, splitErr := net.SplitHostPort(addr) |
| 49 | if splitErr != nil { |
| 50 | host = addr |
| 51 | } |
| 52 | probe := &http.Request{URL: &url.URL{Scheme: "https", Host: host}} |
| 53 | pu, perr := pf(probe) |
| 54 | if perr != nil { |
| 55 | return nil, perr |
| 56 | } |
| 57 | if pu == nil { |
| 58 | return base.DialContext(ctx, network, addr) |
| 59 | } |
| 60 | switch strings.ToLower(pu.Scheme) { |
| 61 | case "socks5", "socks5h": |
| 62 | return dialSOCKS5(ctx, pu, base, network, addr) |
| 63 | case "http", "https": |
| 64 | return dialHTTPConnect(ctx, pu, base, addr) |
| 65 | default: |
| 66 | return nil, fmt.Errorf("netclient: unsupported proxy scheme %q for stream dial", pu.Scheme) |
| 67 | } |
| 68 | }), nil |
| 69 | } |
| 70 | |
| 71 | func dialSOCKS5(ctx context.Context, pu *url.URL, fwd *net.Dialer, network, addr string) (net.Conn, error) { |
| 72 | var auth *proxy.Auth |
| 73 | if pu.User != nil { |
| 74 | pw, _ := pu.User.Password() |
| 75 | auth = &proxy.Auth{User: pu.User.Username(), Password: pw} |
| 76 | } |
| 77 | d, err := proxy.SOCKS5("tcp", pu.Host, auth, fwd) |
| 78 | if err != nil { |
| 79 | return nil, err |
| 80 | } |
| 81 | if cd, ok := d.(proxy.ContextDialer); ok { |
| 82 | return cd.DialContext(ctx, network, addr) |
| 83 | } |
| 84 | return d.Dial(network, addr) |
| 85 | } |
| 86 | |
| 87 | // dialHTTPConnect opens a CONNECT tunnel through an http/https proxy. For an |
| 88 | // https proxy the connection to the proxy itself must be TLS: the CONNECT |
| 89 | // request (including Proxy-Authorization credentials) is sent inside that TLS |
| 90 | // session, not in cleartext. The SSH client then speaks its own protocol over |
| 91 | // the established stream, so only the tunnel handshake lives here. |
| 92 | func dialHTTPConnect(ctx context.Context, pu *url.URL, base *net.Dialer, target string) (net.Conn, error) { |
| 93 | conn, err := base.DialContext(ctx, "tcp", pu.Host) |
| 94 | if err != nil { |
| 95 | return nil, err |
| 96 | } |
| 97 | if strings.EqualFold(pu.Scheme, "https") { |
| 98 | host := pu.Hostname() |
| 99 | tconn := tls.Client(conn, &tls.Config{ServerName: host}) |
| 100 | hsCtx := ctx |
| 101 | if _, ok := ctx.Deadline(); !ok { |
| 102 | var cancel context.CancelFunc |
| 103 | hsCtx, cancel = context.WithTimeout(ctx, 30*time.Second) |
| 104 | defer cancel() |
| 105 | } |
| 106 | if herr := tconn.HandshakeContext(hsCtx); herr != nil { |
| 107 | _ = conn.Close() |
| 108 | return nil, fmt.Errorf("netclient: TLS handshake to https proxy %s: %w", pu.Host, herr) |
| 109 | } |
| 110 | conn = tconn |
| 111 | } |
| 112 | if deadline, ok := ctx.Deadline(); ok { |
| 113 | _ = conn.SetDeadline(deadline) |
| 114 | } |
| 115 | req := &http.Request{ |
| 116 | Method: http.MethodConnect, |
| 117 | URL: &url.URL{Opaque: target}, |
| 118 | Host: target, |
| 119 | Header: make(http.Header), |
| 120 | } |
| 121 | if pu.User != nil { |
| 122 | pw, _ := pu.User.Password() |
| 123 | req.SetBasicAuth(pu.User.Username(), pw) |
| 124 | req.Header.Set("Proxy-Authorization", req.Header.Get("Authorization")) |
| 125 | req.Header.Del("Authorization") |
| 126 | } |
| 127 | if err := req.Write(conn); err != nil { |
| 128 | conn.Close() |
| 129 | return nil, err |
| 130 | } |
| 131 | br := bufio.NewReader(conn) |
| 132 | resp, err := http.ReadResponse(br, req) |
| 133 | if err != nil { |
| 134 | conn.Close() |
| 135 | return nil, err |
| 136 | } |
| 137 | defer resp.Body.Close() |
| 138 | if resp.StatusCode != http.StatusOK { |
| 139 | conn.Close() |
| 140 | return nil, fmt.Errorf("netclient: proxy CONNECT to %s failed: %s", target, resp.Status) |
| 141 | } |
| 142 | // Clear the handshake deadline; the caller manages timeouts thereafter. |
| 143 | _ = conn.SetDeadline(time.Time{}) |
| 144 | if br.Buffered() > 0 { |
| 145 | // A well-behaved proxy sends nothing before the tunnel opens; if it |
| 146 | // did, wrap so buffered bytes aren't lost. |
| 147 | return &bufferedConn{Conn: conn, r: br}, nil |
| 148 | } |
| 149 | return conn, nil |
| 150 | } |
| 151 | |
| 152 | type bufferedConn struct { |
| 153 | net.Conn |
| 154 | r *bufio.Reader |
| 155 | } |
| 156 | |
| 157 | func (b *bufferedConn) Read(p []byte) (int, error) { return b.r.Read(p) } |
| 158 |