From c5eb57f4d67f67aec905d83105acb394e0ee780e Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Thu, 13 Aug 2020 15:25:54 -0700 Subject: [PATCH] net/tshttpproxy: new package, support WPAD/PAC proxies on Windows Updates tailscale/corp#553 Signed-off-by: Brad Fitzpatrick --- control/controlclient/direct.go | 2 + derp/derphttp/derphttp_client.go | 77 ++++++++++++++ go.mod | 2 +- go.sum | 2 + logpolicy/logpolicy.go | 3 + net/interfaces/interfaces.go | 18 ++++ net/tshttpproxy/tshttpproxy.go | 33 ++++++ net/tshttpproxy/tshttpproxy_windows.go | 142 +++++++++++++++++++++++++ 8 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 net/tshttpproxy/tshttpproxy.go create mode 100644 net/tshttpproxy/tshttpproxy_windows.go diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index a7d399daf..2dc710111 100644 --- a/control/controlclient/direct.go +++ b/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) diff --git a/derp/derphttp/derphttp_client.go b/derp/derphttp/derphttp_client.go index 83697c257..b26760893 100644 --- a/derp/derphttp/derphttp_client.go +++ b/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 { diff --git a/go.mod b/go.mod index a833cc7c1..35b643ea8 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 91b9d592a..76f3539fa 100644 --- a/go.sum +++ b/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= diff --git a/logpolicy/logpolicy.go b/logpolicy/logpolicy.go index 41b1c9189..eca6262c7 100644 --- a/logpolicy/logpolicy.go +++ b/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: diff --git a/net/interfaces/interfaces.go b/net/interfaces/interfaces.go index 2d71d4d4e..3ef95c657 100644 --- a/net/interfaces/interfaces.go +++ b/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 } diff --git a/net/tshttpproxy/tshttpproxy.go b/net/tshttpproxy/tshttpproxy.go new file mode 100644 index 000000000..c857c2804 --- /dev/null +++ b/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 +} diff --git a/net/tshttpproxy/tshttpproxy_windows.go b/net/tshttpproxy/tshttpproxy_windows.go new file mode 100644 index 000000000..382df745f --- /dev/null +++ b/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 +}