From fc12cbfcd381b31de905ac431d76fb8c054006a2 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Thu, 24 Mar 2022 09:04:01 -0700 Subject: [PATCH] client/tailscale, cmd/tailscale, localapi: add 'tailscale nc' This adds a "tailscale nc" command that acts a bit like "nc", but dials out via tailscaled via localapi. This is a step towards a "tailscale ssh", as we'll use "tailscale nc" as a ProxyCommand for in some cases (notably in userspace mode). But this is also just useful for debugging & scripting. Updates #3802 RELNOTE=tailscale nc Change-Id: Ia5c37af2d51dd0259d5833d80264d3ad5f68446a Signed-off-by: Brad Fitzpatrick --- client/tailscale/tailscale.go | 56 ++++++++++++++++++++++++++++++++ cmd/tailscale/cli/cli.go | 1 + cmd/tailscale/depaware.txt | 1 + ipn/localapi/localapi.go | 61 +++++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+) diff --git a/client/tailscale/tailscale.go b/client/tailscale/tailscale.go index 273f42282..65610e708 100644 --- a/client/tailscale/tailscale.go +++ b/client/tailscale/tailscale.go @@ -19,6 +19,7 @@ import ( "io/ioutil" "net" "net/http" + "net/http/httptrace" "net/url" "os/exec" "runtime" @@ -31,6 +32,7 @@ import ( "tailscale.com/client/tailscale/apitype" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" + "tailscale.com/net/netutil" "tailscale.com/paths" "tailscale.com/safesocket" "tailscale.com/tailcfg" @@ -419,6 +421,60 @@ func SetDNS(ctx context.Context, name, value string) error { return err } +// DialTCP connects to the host's port via Tailscale. +// +// The host may be a base DNS name (resolved from the netmap inside +// tailscaled), a FQDN, or an IP address. +// +// The ctx is only used for the duration of the call, not the lifetime of the net.Conn. +func DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) { + connCh := make(chan net.Conn, 1) + trace := httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + connCh <- info.Conn + }, + } + ctx = httptrace.WithClientTrace(ctx, &trace) + req, err := http.NewRequestWithContext(ctx, "POST", "http://local-tailscaled.sock/localapi/v0/dial", nil) + if err != nil { + return nil, err + } + req.Header = http.Header{ + "Upgrade": []string{"ts-dial"}, + "Connection": []string{"upgrade"}, + "Dial-Host": []string{host}, + "Dial-Port": []string{fmt.Sprint(port)}, + } + res, err := DoLocalRequest(req) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusSwitchingProtocols { + body, _ := io.ReadAll(res.Body) + res.Body.Close() + return nil, fmt.Errorf("unexpected HTTP response: %s, %s", res.Status, body) + } + // From here on, the underlying net.Conn is ours to use, but there + // is still a read buffer attached to it within resp.Body. So, we + // must direct I/O through resp.Body, but we can still use the + // underlying net.Conn for stuff like deadlines. + var switchedConn net.Conn + select { + case switchedConn = <-connCh: + default: + } + if switchedConn == nil { + res.Body.Close() + return nil, fmt.Errorf("httptrace didn't provide a connection") + } + rwc, ok := res.Body.(io.ReadWriteCloser) + if !ok { + res.Body.Close() + return nil, errors.New("http Transport did not provide a writable body") + } + return netutil.NewAltReadWriteCloserConn(rwc, switchedConn), nil +} + // CurrentDERPMap returns the current DERPMap that is being used by the local tailscaled. // It is intended to be used with netcheck to see availability of DERPs. func CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) { diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 93ea0c60e..5b869ec7a 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -170,6 +170,7 @@ change in the future. ipCmd, statusCmd, pingCmd, + ncCmd, versionCmd, webCmd, fileCmd, diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 16ac6c224..57004663a 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -56,6 +56,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/net/neterror from tailscale.com/net/netcheck+ tailscale.com/net/netknob from tailscale.com/net/netns tailscale.com/net/netns from tailscale.com/derp/derphttp+ + tailscale.com/net/netutil from tailscale.com/client/tailscale tailscale.com/net/packet from tailscale.com/wgengine/filter tailscale.com/net/portmapper from tailscale.com/net/netcheck+ tailscale.com/net/stun from tailscale.com/net/netcheck diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 9d6fcae9d..f47c02c08 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -12,6 +12,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/http/httputil" "net/url" @@ -26,6 +27,7 @@ import ( "tailscale.com/ipn" "tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnstate" + "tailscale.com/net/netutil" "tailscale.com/tailcfg" "tailscale.com/types/logger" "tailscale.com/util/clientmetric" @@ -124,6 +126,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.serveDebug(w, r) case "/localapi/v0/set-expiry-sooner": h.serveSetExpirySooner(w, r) + case "/localapi/v0/dial": + h.serveDial(w, r) case "/": io.WriteString(w, "tailscaled\n") default: @@ -542,6 +546,63 @@ func (h *Handler) serveSetExpirySooner(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "done\n") } +func (h *Handler) serveDial(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + const upgradeProto = "ts-dial" + if !strings.Contains(r.Header.Get("Connection"), "upgrade") || + r.Header.Get("Upgrade") != upgradeProto { + http.Error(w, "bad ts-dial upgrade", http.StatusBadRequest) + return + } + hostStr, portStr := r.Header.Get("Dial-Host"), r.Header.Get("Dial-Port") + if hostStr == "" || portStr == "" { + http.Error(w, "missing Dial-Host or Dial-Port header", http.StatusBadRequest) + return + } + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "make request over HTTP/1", http.StatusBadRequest) + return + } + + addr := net.JoinHostPort(hostStr, portStr) + outConn, err := h.b.Dialer().UserDial(r.Context(), "tcp", addr) + if err != nil { + http.Error(w, "dial failure: "+err.Error(), http.StatusBadGateway) + return + } + defer outConn.Close() + + w.Header().Set("Upgrade", upgradeProto) + w.Header().Set("Connection", "upgrade") + w.WriteHeader(http.StatusSwitchingProtocols) + + reqConn, brw, err := hijacker.Hijack() + if err != nil { + h.logf("localapi dial Hijack error: %v", err) + return + } + defer reqConn.Close() + if err := brw.Flush(); err != nil { + return + } + reqConn = netutil.NewDrainBufConn(reqConn, brw.Reader) + + errc := make(chan error, 1) + go func() { + _, err := io.Copy(reqConn, outConn) + errc <- err + }() + go func() { + _, err := io.Copy(outConn, reqConn) + errc <- err + }() + <-errc +} + func defBool(a string, def bool) bool { if a == "" { return def