From 9c5c9d0a50926f0d43e13f486393245e3e06f91a Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Fri, 3 Dec 2021 08:33:05 -0800 Subject: [PATCH] ipn/ipnlocal, net/tsdial: make SOCKS/HTTP dials use ExitDNS And simplify, unexport some tsdial/netstack stuff in the the process. Fixes #3475 Change-Id: I186a5a5cbd8958e25c075b4676f7f6e70f3ff76e Signed-off-by: Brad Fitzpatrick --- cmd/tailscaled/tailscaled.go | 2 +- ipn/ipnlocal/local.go | 9 ++++ net/tsdial/dohclient.go | 86 +++++++++++++++++++++++++++++++++++ net/tsdial/dohclient_test.go | 32 +++++++++++++ net/tsdial/tsdial.go | 53 +++++++++++++++++---- tsnet/tsnet.go | 2 +- wgengine/netstack/netstack.go | 24 ++++------ 7 files changed, 181 insertions(+), 27 deletions(-) create mode 100644 net/tsdial/dohclient.go create mode 100644 net/tsdial/dohclient_test.go diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go index c2dffaefc..ee7792303 100644 --- a/cmd/tailscaled/tailscaled.go +++ b/cmd/tailscaled/tailscaled.go @@ -333,7 +333,7 @@ func run() error { return ok } dialer.NetstackDialTCP = func(ctx context.Context, dst netaddr.IPPort) (net.Conn, error) { - return ns.DialContextTCP(ctx, dst.String()) + return ns.DialContextTCP(ctx, dst) } } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index c21da1912..ca5a62e62 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -1898,6 +1898,15 @@ func (b *LocalBackend) authReconfig() { } } + // Keep the dialer updated about whether we're supposed to use + // an exit node's DNS server (so SOCKS5/HTTP outgoing dials + // can use it for name resolution) + if dohURL, ok := exitNodeCanProxyDNS(nm, prefs.ExitNodeID); ok { + b.dialer.SetExitDNSDoH(dohURL) + } else { + b.dialer.SetExitDNSDoH("") + } + cfg, err := nmcfg.WGCfg(nm, b.logf, flags, prefs.ExitNodeID) if err != nil { b.logf("wgcfg: %v", err) diff --git a/net/tsdial/dohclient.go b/net/tsdial/dohclient.go new file mode 100644 index 000000000..3ff11d4a9 --- /dev/null +++ b/net/tsdial/dohclient.go @@ -0,0 +1,86 @@ +// Copyright (c) 2021 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 tsdial + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "time" +) + +// dohConn is a net.PacketConn suitable for returning from +// net.Dialer.Dial to send DNS queries over PeerAPI to exit nodes' +// ExitDNS DoH proxy service. +type dohConn struct { + ctx context.Context + baseURL string + hc *http.Client // if nil, default is used + + rbuf bytes.Buffer +} + +var ( + _ net.Conn = (*dohConn)(nil) + _ net.PacketConn = (*dohConn)(nil) // be a PacketConn to change net.Resolver semantics +) + +func (*dohConn) Close() error { return nil } +func (*dohConn) LocalAddr() net.Addr { return todoAddr{} } +func (*dohConn) RemoteAddr() net.Addr { return todoAddr{} } +func (*dohConn) SetDeadline(t time.Time) error { return nil } +func (*dohConn) SetReadDeadline(t time.Time) error { return nil } +func (*dohConn) SetWriteDeadline(t time.Time) error { return nil } + +func (c *dohConn) WriteTo(p []byte, addr net.Addr) (n int, err error) { + return c.Write(p) +} + +func (c *dohConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + n, err = c.Read(p) + return n, todoAddr{}, err +} + +func (c *dohConn) Read(p []byte) (n int, err error) { + return c.rbuf.Read(p) +} + +func (c *dohConn) Write(packet []byte) (n int, err error) { + req, err := http.NewRequestWithContext(c.ctx, "POST", c.baseURL, bytes.NewReader(packet)) + if err != nil { + return 0, err + } + const dohType = "application/dns-message" + req.Header.Set("Content-Type", dohType) + hc := c.hc + if hc == nil { + hc = http.DefaultClient + } + hres, err := hc.Do(req) + if err != nil { + return 0, err + } + defer hres.Body.Close() + if hres.StatusCode != 200 { + return 0, errors.New(hres.Status) + } + if ct := hres.Header.Get("Content-Type"); ct != dohType { + return 0, fmt.Errorf("unexpected response Content-Type %q", ct) + } + _, err = io.Copy(&c.rbuf, hres.Body) + if err != nil { + return 0, err + } + return len(packet), nil +} + +type todoAddr struct{} + +func (todoAddr) Network() string { return "unused" } +func (todoAddr) String() string { return "unused-todoAddr" } diff --git a/net/tsdial/dohclient_test.go b/net/tsdial/dohclient_test.go new file mode 100644 index 000000000..eeffac76a --- /dev/null +++ b/net/tsdial/dohclient_test.go @@ -0,0 +1,32 @@ +// Copyright (c) 2021 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 tsdial + +import ( + "context" + "flag" + "net" + "testing" + "time" +) + +var dohBase = flag.String("doh-base", "", "DoH base URL for manual DoH tests; e.g. \"http://100.68.82.120:47830/dns-query\"") + +func TestDoHResolve(t *testing.T) { + if *dohBase == "" { + t.Skip("skipping manual test without --doh-base= set") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var r net.Resolver + r.Dial = func(ctx context.Context, network, address string) (net.Conn, error) { + return &dohConn{ctx: ctx, baseURL: *dohBase}, nil + } + addrs, err := r.LookupIP(ctx, "ip4", "google.com.") + if err != nil { + t.Fatal(err) + } + t.Logf("Got: %q", addrs) +} diff --git a/net/tsdial/tsdial.go b/net/tsdial/tsdial.go index 3a27b2bed..6e9d237b8 100644 --- a/net/tsdial/tsdial.go +++ b/net/tsdial/tsdial.go @@ -11,6 +11,7 @@ "fmt" "net" "net/http" + "strings" "sync" "sync/atomic" "syscall" @@ -43,10 +44,11 @@ type Dialer struct { peerDialerOnce sync.Once peerDialer *net.Dialer - mu sync.Mutex - dns dnsMap - tunName string // tun device name - linkMon *monitor.Mon + mu sync.Mutex + dns dnsMap + tunName string // tun device name + linkMon *monitor.Mon + exitDNSDoHBase string // non-empty if DoH-proxying exit node in use; base URL+path (without '?') } // SetTUNName sets the name of the tun device in use ("tailscale0", "utun6", @@ -66,6 +68,17 @@ func (d *Dialer) TUNName() string { return d.tunName } +// SetExitDNSDoH sets (or clears) the exit node DNS DoH server base URL to use. +// The doh URL should contain the scheme, authority, and path, but without +// a '?' and/or query parameters. +// +// For example, "http://100.68.82.120:47830/dns-query". +func (d *Dialer) SetExitDNSDoH(doh string) { + d.mu.Lock() + defer d.mu.Unlock() + d.exitDNSDoHBase = doh +} + func (d *Dialer) SetLinkMonitor(mon *monitor.Mon) { d.mu.Lock() defer d.mu.Unlock() @@ -113,21 +126,20 @@ func (d *Dialer) SetNetMap(nm *netmap.NetworkMap) { d.dns = m } -func (d *Dialer) Resolve(ctx context.Context, network, addr string) (netaddr.IPPort, error) { +func (d *Dialer) userDialResolve(ctx context.Context, network, addr string) (netaddr.IPPort, error) { d.mu.Lock() dns := d.dns + exitDNSDoH := d.exitDNSDoHBase d.mu.Unlock() // MagicDNS or otherwise baked in to the NetworkMap? Try that first. ipp, err := dns.resolveMemory(ctx, network, addr) - if err != errUnresolved { return ipp, err } // Otherwise, hit the network. - // TODO(bradfitz): use ExitDNS (Issue 3475) // TODO(bradfitz): wire up net/dnscache too. host, port, err := splitHostPort(addr) @@ -137,7 +149,17 @@ func (d *Dialer) Resolve(ctx context.Context, network, addr string) (netaddr.IPP } var r net.Resolver - ips, err := r.LookupIP(ctx, network, host) + if exitDNSDoH != "" { + r.Dial = func(ctx context.Context, network, address string) (net.Conn, error) { + return &dohConn{ + ctx: ctx, + baseURL: exitDNSDoH, + hc: d.PeerAPIHTTPClient(), + }, nil + } + } + + ips, err := r.LookupIP(ctx, ipNetOfNetwork(network), host) if err != nil { return netaddr.IPPort{}, err } @@ -148,10 +170,23 @@ func (d *Dialer) Resolve(ctx context.Context, network, addr string) (netaddr.IPP return netaddr.IPPortFrom(ip, port), nil } +// ipNetOfNetwork returns "ip", "ip4", or "ip6" corresponding +// to the input value of "tcp", "tcp4", "udp6" etc network +// names. +func ipNetOfNetwork(n string) string { + if strings.HasSuffix(n, "4") { + return "ip4" + } + if strings.HasSuffix(n, "6") { + return "ip6" + } + return "ip" +} + // UserDial connects to the provided network address as if a user were initiating the dial. // (e.g. from a SOCKS or HTTP outbound proxy) func (d *Dialer) UserDial(ctx context.Context, network, addr string) (net.Conn, error) { - ipp, err := d.Resolve(ctx, network, addr) + ipp, err := d.userDialResolve(ctx, network, addr) if err != nil { return nil, err } diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index fd59b51da..9ac6a2022 100644 --- a/tsnet/tsnet.go +++ b/tsnet/tsnet.go @@ -146,7 +146,7 @@ func (s *Server) start() error { return ok } dialer.NetstackDialTCP = func(ctx context.Context, dst netaddr.IPPort) (net.Conn, error) { - return ns.DialContextTCP(ctx, dst.String()) + return ns.DialContextTCP(ctx, dst) } statePath := filepath.Join(s.dir, "tailscaled.state") diff --git a/wgengine/netstack/netstack.go b/wgengine/netstack/netstack.go index f30c06ab1..dfaca5242 100644 --- a/wgengine/netstack/netstack.go +++ b/wgengine/netstack/netstack.go @@ -295,18 +295,14 @@ func (ns *Impl) updateIPs(nm *netmap.NetworkMap) { } } -func (ns *Impl) DialContextTCP(ctx context.Context, addr string) (*gonet.TCPConn, error) { - remoteIPPort, err := ns.dialer.Resolve(ctx, "tcp", addr) - if err != nil { - return nil, err - } +func (ns *Impl) DialContextTCP(ctx context.Context, ipp netaddr.IPPort) (*gonet.TCPConn, error) { remoteAddress := tcpip.FullAddress{ NIC: nicID, - Addr: tcpip.Address(remoteIPPort.IP().IPAddr().IP), - Port: remoteIPPort.Port(), + Addr: tcpip.Address(ipp.IP().IPAddr().IP), + Port: ipp.Port(), } var ipType tcpip.NetworkProtocolNumber - if remoteIPPort.IP().Is4() { + if ipp.IP().Is4() { ipType = ipv4.ProtocolNumber } else { ipType = ipv6.ProtocolNumber @@ -315,18 +311,14 @@ func (ns *Impl) DialContextTCP(ctx context.Context, addr string) (*gonet.TCPConn return gonet.DialContextTCP(ctx, ns.ipstack, remoteAddress, ipType) } -func (ns *Impl) DialContextUDP(ctx context.Context, addr string) (*gonet.UDPConn, error) { - remoteIPPort, err := ns.dialer.Resolve(ctx, "udp", addr) - if err != nil { - return nil, err - } +func (ns *Impl) DialContextUDP(ctx context.Context, ipp netaddr.IPPort) (*gonet.UDPConn, error) { remoteAddress := &tcpip.FullAddress{ NIC: nicID, - Addr: tcpip.Address(remoteIPPort.IP().IPAddr().IP), - Port: remoteIPPort.Port(), + Addr: tcpip.Address(ipp.IP().IPAddr().IP), + Port: ipp.Port(), } var ipType tcpip.NetworkProtocolNumber - if remoteIPPort.IP().Is4() { + if ipp.IP().Is4() { ipType = ipv4.ProtocolNumber } else { ipType = ipv6.ProtocolNumber