Browse Source

net/tshttpproxy: new package, support WPAD/PAC proxies on Windows

Updates tailscale/corp#553

Signed-off-by: Brad Fitzpatrick <[email protected]>
Brad Fitzpatrick 5 years ago
parent
commit
c5eb57f4d6

+ 2 - 0
control/controlclient/direct.go

@@ -36,6 +36,7 @@ import (
 	"tailscale.com/log/logheap"
 	"tailscale.com/net/netns"
 	"tailscale.com/net/tlsdial"
+	"tailscale.com/net/tshttpproxy"
 	"tailscale.com/tailcfg"
 	"tailscale.com/types/logger"
 	"tailscale.com/types/opt"
@@ -147,6 +148,7 @@ func NewDirect(opts Options) (*Direct, error) {
 	if httpc == nil {
 		dialer := netns.NewDialer()
 		tr := http.DefaultTransport.(*http.Transport).Clone()
+		tr.Proxy = tshttpproxy.ProxyFromEnvironment
 		tr.DialContext = dialer.DialContext
 		tr.ForceAttemptHTTP2 = true
 		tr.TLSClientConfig = tlsdial.Config(serverURL.Host, tr.TLSClientConfig)

+ 77 - 0
derp/derphttp/derphttp_client.go

@@ -29,6 +29,7 @@ import (
 	"tailscale.com/net/dnscache"
 	"tailscale.com/net/netns"
 	"tailscale.com/net/tlsdial"
+	"tailscale.com/net/tshttpproxy"
 	"tailscale.com/tailcfg"
 	"tailscale.com/types/key"
 	"tailscale.com/types/logger"
@@ -420,6 +421,19 @@ const dialNodeTimeout = 1500 * time.Millisecond
 // TODO(bradfitz): longer if no options remain perhaps? ...  Or longer
 // overall but have dialRegion start overlapping races?
 func (c *Client) dialNode(ctx context.Context, n *tailcfg.DERPNode) (net.Conn, error) {
+	// First see if we need to use an HTTP proxy.
+	proxyReq := &http.Request{
+		Method: "GET", // doesn't really matter
+		URL: &url.URL{
+			Scheme: "https",
+			Host:   c.tlsServerName(n),
+			Path:   "/", // unused
+		},
+	}
+	if proxyURL, err := tshttpproxy.ProxyFromEnvironment(proxyReq); err == nil && proxyURL != nil {
+		return c.dialNodeUsingProxy(ctx, n, proxyURL)
+	}
+
 	type res struct {
 		c   net.Conn
 		err error
@@ -480,6 +494,69 @@ func (c *Client) dialNode(ctx context.Context, n *tailcfg.DERPNode) (net.Conn, e
 	}
 }
 
+func firstStr(a, b string) string {
+	if a != "" {
+		return a
+	}
+	return b
+}
+
+// dialNodeUsingProxy connects to n using a CONNECT to the HTTP(s) proxy in proxyURL.
+func (c *Client) dialNodeUsingProxy(ctx context.Context, n *tailcfg.DERPNode, proxyURL *url.URL) (proxyConn net.Conn, err error) {
+	pu := proxyURL
+	if pu.Scheme == "https" {
+		var d tls.Dialer
+		proxyConn, err = d.DialContext(ctx, "tcp", net.JoinHostPort(pu.Hostname(), firstStr(pu.Port(), "443")))
+	} else {
+		var d net.Dialer
+		proxyConn, err = d.DialContext(ctx, "tcp", net.JoinHostPort(pu.Hostname(), firstStr(pu.Port(), "80")))
+	}
+	defer func() {
+		if err != nil && proxyConn != nil {
+			// In a goroutine in case it's a *tls.Conn (that can block on Close)
+			// TODO(bradfitz): track the underlying tcp.Conn and just close that instead.
+			go proxyConn.Close()
+		}
+	}()
+	if err != nil {
+		return nil, err
+	}
+
+	done := make(chan struct{})
+	defer close(done)
+	go func() {
+		select {
+		case <-done:
+			return
+		case <-ctx.Done():
+			proxyConn.Close()
+		}
+	}()
+
+	target := net.JoinHostPort(n.HostName, "443")
+	if _, err := fmt.Fprintf(proxyConn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, pu.Hostname()); err != nil {
+		if ctx.Err() != nil {
+			return nil, ctx.Err()
+		}
+		return nil, err
+	}
+
+	br := bufio.NewReader(proxyConn)
+	res, err := http.ReadResponse(br, nil)
+	if err != nil {
+		if ctx.Err() != nil {
+			return nil, ctx.Err()
+		}
+		c.logf("derphttp: CONNECT dial to %s: %v", target, err)
+		return nil, err
+	}
+	c.logf("derphttp: CONNECT dial to %s: %v", target, res.Status)
+	if res.StatusCode != 200 {
+		return nil, fmt.Errorf("invalid response status from HTTP proxy %s on CONNECT to %s: %v", pu, target, res.Status)
+	}
+	return proxyConn, nil
+}
+
 func (c *Client) Send(dstKey key.Public, b []byte) error {
 	client, _, err := c.connect(context.TODO(), "derphttp.Client.Send")
 	if err != nil {

+ 1 - 1
go.mod

@@ -29,7 +29,7 @@ require (
 	golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5
 	golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
 	golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e
-	golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3
+	golang.org/x/sys v0.0.0-20200812155832-6a926be9bd1d
 	golang.org/x/time v0.0.0-20191024005414-555d28b269f0
 	golang.org/x/tools v0.0.0-20191216052735-49a3e744a425
 	honnef.co/go/tools v0.0.1-2020.1.4

+ 2 - 0
go.sum

@@ -141,6 +141,8 @@ golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3 h1:5B6i6EAiSYyejWfvc5Rc9BbI3rzIsrrXfAQBWnYfn+w=
 golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200812155832-6a926be9bd1d h1:QQrM/CCYEzTs91GZylDCQjGHudbPTxF/1fvXdVh5lMo=
+golang.org/x/sys v0.0.0-20200812155832-6a926be9bd1d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=

+ 3 - 0
logpolicy/logpolicy.go

@@ -31,6 +31,7 @@ import (
 	"tailscale.com/logtail/filch"
 	"tailscale.com/net/netns"
 	"tailscale.com/net/tlsdial"
+	"tailscale.com/net/tshttpproxy"
 	"tailscale.com/paths"
 	"tailscale.com/smallzstd"
 	"tailscale.com/types/logger"
@@ -431,6 +432,8 @@ func newLogtailTransport(host string) *http.Transport {
 	// Start with a copy of http.DefaultTransport and tweak it a bit.
 	tr := http.DefaultTransport.(*http.Transport).Clone()
 
+	tr.Proxy = tshttpproxy.ProxyFromEnvironment
+
 	// We do our own zstd compression on uploads, and responses never contain any payload,
 	// so don't send "Accept-Encoding: gzip" to save a few bytes on the wire, since there
 	// will never be any body to decompress:

+ 18 - 0
net/interfaces/interfaces.go

@@ -8,13 +8,19 @@ package interfaces
 import (
 	"fmt"
 	"net"
+	"net/http"
 	"reflect"
 	"strings"
 
 	"inet.af/netaddr"
 	"tailscale.com/net/tsaddr"
+	"tailscale.com/net/tshttpproxy"
 )
 
+// LoginEndpointForProxyDetermination is the URL used for testing
+// which HTTP proxy the system should use.
+var LoginEndpointForProxyDetermination = "https://login.tailscale.com/"
+
 // Tailscale returns the current machine's Tailscale interface, if any.
 // If none is found, all zero values are returned.
 // A non-nil error is only returned on a problem listing the system interfaces.
@@ -168,6 +174,9 @@ type State struct {
 	// DefaultRouteInterface is the interface name for the machine's default route.
 	// It is not yet populated on all OSes.
 	DefaultRouteInterface string
+
+	// HTTPProxy is the HTTP proxy to use.
+	HTTPProxy string
 }
 
 func (s *State) Equal(s2 *State) bool {
@@ -205,6 +214,15 @@ func GetState() (*State, error) {
 		return nil, err
 	}
 	s.DefaultRouteInterface, _ = DefaultRouteInterface()
+
+	req, err := http.NewRequest("GET", LoginEndpointForProxyDetermination, nil)
+	if err != nil {
+		return nil, err
+	}
+	if u, err := tshttpproxy.ProxyFromEnvironment(req); err == nil && u != nil {
+		s.HTTPProxy = u.String()
+	}
+
 	return s, nil
 }
 

+ 33 - 0
net/tshttpproxy/tshttpproxy.go

@@ -0,0 +1,33 @@
+// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package tshttpproxy contains Tailscale additions to httpproxy not available
+// in golang.org/x/net/http/httpproxy. Notably, it aims to support Windows better.
+package tshttpproxy
+
+import (
+	"net/http"
+	"net/url"
+)
+
+// sysProxyFromEnv, if non-nil, specifies a platform-specific ProxyFromEnvironment
+// func to use if http.ProxyFromEnvironment doesn't return a proxy.
+// For example, WPAD PAC files on Windows.
+var sysProxyFromEnv func(*http.Request) (*url.URL, error)
+
+func ProxyFromEnvironment(req *http.Request) (*url.URL, error) {
+	u, err := http.ProxyFromEnvironment(req)
+	if u != nil && err == nil {
+		return u, nil
+	}
+
+	if sysProxyFromEnv != nil {
+		u, err := sysProxyFromEnv(req)
+		if u != nil && err == nil {
+			return u, nil
+		}
+	}
+
+	return nil, err
+}

+ 142 - 0
net/tshttpproxy/tshttpproxy_windows.go

@@ -0,0 +1,142 @@
+// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package tshttpproxy
+
+import (
+	"log"
+	"net/http"
+	"net/url"
+	"strings"
+	"syscall"
+	"unsafe"
+
+	"golang.org/x/sys/windows"
+)
+
+var (
+	winHTTP            = windows.NewLazySystemDLL("winhttp.dll")
+	httpOpenProc       = winHTTP.NewProc("WinHttpOpen")
+	closeHandleProc    = winHTTP.NewProc("WinHttpCloseHandle")
+	getProxyForUrlProc = winHTTP.NewProc("WinHttpGetProxyForUrl")
+)
+
+func init() {
+	sysProxyFromEnv = proxyFromWinHTTP
+}
+
+func proxyFromWinHTTP(req *http.Request) (*url.URL, error) {
+	if req.URL == nil {
+		return nil, nil
+	}
+	urlStr := req.URL.String()
+
+	whi, err := winHTTPOpen()
+	if err != nil {
+		// Log but otherwise ignore the error.
+		log.Printf("winhttp: Open: %v", err)
+		return nil, nil
+	}
+	defer whi.Close()
+
+	v, err := whi.GetProxyForURL(urlStr)
+	if err != nil {
+		// See https://docs.microsoft.com/en-us/windows/win32/winhttp/error-messages
+		const ERROR_WINHTTP_AUTODETECTION_FAILED = 12180
+		if err == syscall.Errno(ERROR_WINHTTP_AUTODETECTION_FAILED) {
+			return nil, nil
+		}
+		log.Printf("winhttp: GetProxyForURL(%q): %v (%T, %#v)", urlStr, err, err, err)
+		return nil, nil
+	}
+	if v != "" {
+		if !strings.HasPrefix(v, "https://") {
+			v = "http://" + v
+		}
+		if u, err := url.Parse(v); err == nil {
+			return u, nil
+		}
+	}
+
+	return nil, nil
+}
+
+var userAgent = windows.StringToUTF16Ptr("Tailscale")
+
+const (
+	winHTTP_ACCESS_TYPE_AUTOMATIC_PROXY = 4
+	winHTTP_AUTOPROXY_ALLOW_AUTOCONFIG  = 0x00000100
+	winHTTP_AUTOPROXY_AUTO_DETECT       = 1
+	winHTTP_AUTO_DETECT_TYPE_DHCP       = 0x00000001
+	winHTTP_AUTO_DETECT_TYPE_DNS_A      = 0x00000002
+)
+
+func winHTTPOpen() (winHTTPInternet, error) {
+	if err := httpOpenProc.Find(); err != nil {
+		return 0, err
+	}
+	r, _, err := httpOpenProc.Call(
+		uintptr(unsafe.Pointer(userAgent)),
+		winHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
+		0, /* WINHTTP_NO_PROXY_NAME */
+		0, /* WINHTTP_NO_PROXY_BYPASS */
+		0)
+	if r == 0 {
+		return 0, err
+	}
+	return winHTTPInternet(r), nil
+}
+
+type winHTTPInternet windows.Handle
+
+func (hi winHTTPInternet) Close() error {
+	if err := closeHandleProc.Find(); err != nil {
+		return err
+	}
+	r, _, err := closeHandleProc.Call(uintptr(hi))
+	if r == 1 {
+		return nil
+	}
+	return err
+}
+
+// WINHTTP_AUTOPROXY_OPTIONS
+// https://docs.microsoft.com/en-us/windows/win32/api/winhttp/ns-winhttp-winhttp_autoproxy_options
+type autoProxyOptions struct {
+	DwFlags                uint32
+	DwAutoDetectFlags      uint32
+	AutoConfigUrl          *uint16
+	_                      uintptr
+	_                      uint32
+	FAutoLogonIfChallenged bool
+}
+
+// WINHTTP_PROXY_INFO
+// https://docs.microsoft.com/en-us/windows/win32/api/winhttp/ns-winhttp-winhttp_proxy_info
+type winHTTPProxyInfo struct {
+	AccessType  uint16
+	Proxy       *uint16
+	ProxyBypass *uint16
+}
+
+var proxyForURLOpts = &autoProxyOptions{
+	DwFlags:           winHTTP_AUTOPROXY_ALLOW_AUTOCONFIG | winHTTP_AUTOPROXY_AUTO_DETECT,
+	DwAutoDetectFlags: winHTTP_AUTO_DETECT_TYPE_DHCP, // | winHTTP_AUTO_DETECT_TYPE_DNS_A,
+}
+
+func (hi winHTTPInternet) GetProxyForURL(urlStr string) (string, error) {
+	if err := getProxyForUrlProc.Find(); err != nil {
+		return "", err
+	}
+	var out winHTTPProxyInfo
+	r, _, err := getProxyForUrlProc.Call(
+		uintptr(hi),
+		uintptr(unsafe.Pointer(windows.StringToUTF16Ptr(urlStr))),
+		uintptr(unsafe.Pointer(proxyForURLOpts)),
+		uintptr(unsafe.Pointer(&out)))
+	if r == 1 {
+		return windows.UTF16PtrToString(out.Proxy), nil
+	}
+	return "", err
+}