summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBrad Fitzpatrick <bradfitz@tailscale.com>2022-10-07 21:00:42 -0700
committerBrad Fitzpatrick <bradfitz@tailscale.com>2022-10-10 14:48:38 -0700
commit6438ad54b18ed63c15a1abcfa2ac8fd53910b193 (patch)
treedc6d1624172a87f5254d4d5110471c58d932bdec
parent0475ed4a7e28a10f2f9e1b51bc1b07b957bea1c9 (diff)
downloadtailscale-bradfitz/tailpipe.tar.xz
tailscale-bradfitz/tailpipe.zip
WIP tailpipebradfitz/tailpipe
Updates #nnn Change-Id: I719479e4cd58c487b4d987ab563689caacce1549 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
-rw-r--r--client/tailscale/localclient.go65
-rw-r--r--cmd/tailscale/cli/cli.go1
-rw-r--r--cmd/tailscale/cli/nc.go89
-rw-r--r--ipn/ipnlocal/tailpipe.go104
-rw-r--r--ipn/localapi/localapi.go36
-rw-r--r--tailcfg/tailcfg.go2
6 files changed, 279 insertions, 18 deletions
diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go
index 01b7a08fd..ce6bd95de 100644
--- a/client/tailscale/localclient.go
+++ b/client/tailscale/localclient.go
@@ -19,6 +19,7 @@ import (
"net/http"
"net/http/httptrace"
"net/netip"
+ "net/textproto"
"net/url"
"os/exec"
"runtime"
@@ -558,6 +559,15 @@ func (lc *LocalClient) SetDNS(ctx context.Context, name, value string) error {
//
// The ctx is only used for the duration of the call, not the lifetime of the net.Conn.
func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
+ return lc.dialViaLocalAPI(ctx, host, fmt.Sprint(port))
+}
+
+// DialTCPNamedPort is like DialTCP but takes a named port rather than an integer.
+func (lc *LocalClient) DialTCPNamedPort(ctx context.Context, host, portName string) (net.Conn, error) {
+ return lc.dialViaLocalAPI(ctx, host, portName)
+}
+
+func (lc *LocalClient) dialViaLocalAPI(ctx context.Context, host, port string) (net.Conn, error) {
connCh := make(chan net.Conn, 1)
trace := httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
@@ -573,7 +583,7 @@ func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (n
"Upgrade": []string{"ts-dial"},
"Connection": []string{"upgrade"},
"Dial-Host": []string{host},
- "Dial-Port": []string{fmt.Sprint(port)},
+ "Dial-Port": []string{port},
}
res, err := lc.DoLocalRequest(req)
if err != nil {
@@ -605,6 +615,59 @@ func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (n
return netutil.NewAltReadWriteCloserConn(rwc, switchedConn), nil
}
+// ListenNewRandomPortName...
+func (lc *LocalClient) ListenNewRandomPortName(ctx context.Context) (portName string, accept func(context.Context) (net.Conn, error), err error) {
+ connCh := make(chan net.Conn, 1)
+ portNameCh := make(chan string, 1)
+ trace := httptrace.ClientTrace{
+ GotConn: func(info httptrace.GotConnInfo) {
+ connCh <- info.Conn
+ },
+ Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
+ portNameCh <- fmt.Sprintf("Got %v, %v", code, header)
+ return nil
+ },
+ }
+ ctx = httptrace.WithClientTrace(ctx, &trace)
+ req, err := http.NewRequestWithContext(ctx, "POST", "http://local-tailscaled.sock/localapi/v0/open-bidi-pipe", nil)
+ if err != nil {
+ return "", nil, err
+ }
+ req.Header = http.Header{
+ "Upgrade": []string{"ts-open-bidi-pipe"},
+ "Connection": []string{"upgrade"},
+ }
+
+ doErrc := make(chan error, 1)
+
+ go func() {
+ res, err := lc.DoLocalRequest(req)
+ if err != nil {
+ doErrc <- err
+ return
+ }
+ _ = res
+ }()
+
+ accept = func(ctx context.Context) (net.Conn, error) {
+ panic("TODO")
+ }
+
+ select {
+ case name := <-portNameCh:
+ return name, accept, nil
+ case err := <-doErrc:
+ 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)
+ // }
+ panic("TODO")
+}
+
// 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 (lc *LocalClient) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go
index 00fc24296..81f728284 100644
--- a/cmd/tailscale/cli/cli.go
+++ b/cmd/tailscale/cli/cli.go
@@ -34,6 +34,7 @@ import (
var Stderr io.Writer = os.Stderr
var Stdout io.Writer = os.Stdout
+var Stdin io.Reader = os.Stdin
func printf(format string, a ...any) {
fmt.Fprintf(Stdout, format, a...)
diff --git a/cmd/tailscale/cli/nc.go b/cmd/tailscale/cli/nc.go
index 1f9edb185..66a421b67 100644
--- a/cmd/tailscale/cli/nc.go
+++ b/cmd/tailscale/cli/nc.go
@@ -7,55 +7,114 @@ package cli
import (
"context"
"errors"
+ "flag"
"fmt"
"io"
+ "net"
"os"
"strconv"
+ "strings"
"github.com/peterbourgon/ff/v3/ffcli"
+ "tailscale.com/ipn/ipnstate"
)
var ncCmd = &ffcli.Command{
Name: "nc",
- ShortUsage: "nc <hostname-or-IP> <port>",
+ ShortUsage: "nc <hostname-or-IP> <port>\n nc -l",
ShortHelp: "Connect to a port on a host, connected to stdin/stdout",
Exec: runNC,
+ FlagSet: func() *flag.FlagSet {
+ fs := flag.NewFlagSet("nc", flag.ExitOnError)
+ fs.BoolVar(&ncArgs.listen, "l", false, "whether to listen for incoming connections (\"Tailpipe\")")
+ return fs
+ }(),
+}
+
+var ncArgs struct {
+ listen bool
}
func runNC(ctx context.Context, args []string) error {
+ if ncArgs.listen {
+ if len(args) != 0 {
+ return errors.New("no arguments supported with -l")
+ }
+ return runNCListen(ctx)
+ }
+
+ if len(args) != 2 {
+ return errors.New("usage: nc <hostname-or-IP> <port>")
+ }
+
+ if _, err := checkRunning(ctx); err != nil {
+ return err
+ }
+ hostOrIP, portStr := args[0], args[1]
+
+ var c net.Conn
+ var err error
+ if strings.HasPrefix(portStr, "tailpipe-") {
+ c, err = localClient.DialTCPNamedPort(ctx, hostOrIP, portStr)
+ } else {
+ port, err := strconv.ParseUint(portStr, 10, 16)
+ if err != nil {
+ return fmt.Errorf("invalid port number %q", portStr)
+ }
+ // TODO(bradfitz): also add UDP too, via flag?
+ c, err = localClient.DialTCP(ctx, hostOrIP, uint16(port))
+ }
+ if err != nil {
+ return fmt.Errorf("Dial(%q, %v): %w", hostOrIP, portStr, err)
+ }
+ defer c.Close()
+ errc := make(chan error, 1)
+ go func() {
+ _, err := io.Copy(os.Stdout, c)
+ errc <- err
+ }()
+ go func() {
+ _, err := io.Copy(c, os.Stdin)
+ errc <- err
+ }()
+ return <-errc
+}
+
+func checkRunning(ctx context.Context) (*ipnstate.Status, error) {
st, err := localClient.Status(ctx)
if err != nil {
- return fixTailscaledConnectError(err)
+ return nil, fixTailscaledConnectError(err)
}
description, ok := isRunningOrStarting(st)
if !ok {
printf("%s\n", description)
os.Exit(1)
}
+ return st, err
+}
- if len(args) != 2 {
- return errors.New("usage: nc <hostname-or-IP> <port>")
+// runNCLIsten opens a tailpipe.
+func runNCListen(ctx context.Context) error {
+ st, err := checkRunning(ctx)
+ if err != nil {
+ return err
}
-
- hostOrIP, portStr := args[0], args[1]
- port, err := strconv.ParseUint(portStr, 10, 16)
+ portName, accept, err := localClient.ListenNewRandomPortName(ctx)
if err != nil {
- return fmt.Errorf("invalid port number %q", portStr)
+ return err
}
-
- // TODO(bradfitz): also add UDP too, via flag?
- c, err := localClient.DialTCP(ctx, hostOrIP, uint16(port))
+ fmt.Fprintf(Stderr, "Port opened. Connect with: nc %v %v\n", st.Self.Addrs[0], portName)
+ c, err := accept(ctx)
if err != nil {
- return fmt.Errorf("Dial(%q, %v): %w", hostOrIP, port, err)
+ return err
}
- defer c.Close()
errc := make(chan error, 1)
go func() {
- _, err := io.Copy(os.Stdout, c)
+ _, err := io.Copy(Stdout, c)
errc <- err
}()
go func() {
- _, err := io.Copy(c, os.Stdin)
+ _, err := io.Copy(c, Stdin)
errc <- err
}()
return <-errc
diff --git a/ipn/ipnlocal/tailpipe.go b/ipn/ipnlocal/tailpipe.go
new file mode 100644
index 000000000..876f9a0e5
--- /dev/null
+++ b/ipn/ipnlocal/tailpipe.go
@@ -0,0 +1,104 @@
+// Copyright (c) 2022 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 ipnlocal
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptrace"
+ "net/netip"
+
+ "golang.org/x/exp/slices"
+ "tailscale.com/ipn"
+ "tailscale.com/net/netutil"
+ "tailscale.com/net/tsaddr"
+ "tailscale.com/tailcfg"
+)
+
+// PipeDialPeerAPIURL ....
+func (b *LocalBackend) PipeDialPeerAPIURL(ip netip.Addr) (peerAPIURL string, err error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ nm := b.netMap
+ if b.state != ipn.Running || nm == nil {
+ return "", errors.New("not connected to the tailnet")
+ }
+ ipa := netip.PrefixFrom(ip, ip.BitLen())
+ for _, p := range nm.Peers {
+ if slices.Contains(p.Addresses, ipa) {
+ if p.User == nm.User ||
+ len(p.Addresses) > 0 && b.peerHasCapLocked(p.Addresses[0].Addr(), tailcfg.CapabilityTailpipeTarget) {
+ peerAPI := peerAPIBase(b.netMap, p)
+ if peerAPI == "" {
+ continue
+ }
+ }
+ return "", errors.New("invalid target")
+ }
+ }
+ return "", errors.New("target not found")
+}
+
+func (b *LocalBackend) DialTailpipe(ctx context.Context, tailscaleIPStr, portName string) (net.Conn, error) {
+ ip, err := netip.ParseAddr(tailscaleIPStr)
+ if err != nil || !tsaddr.IsTailscaleIP(ip) {
+ return nil, fmt.Errorf("host must be a Tailscale IP for now, not %q", tailscaleIPStr)
+ }
+
+ hc := b.dialer.PeerAPIHTTPClient()
+
+ peerAPIBase, err := b.PipeDialPeerAPIURL(ip)
+ if err != nil {
+ return nil, err
+ }
+ 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", peerAPIBase+"/localapi/v0/connect-to-open-tailpipe", nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header = http.Header{
+ "Upgrade": []string{"tailpipe"},
+ "Connection": []string{"upgrade"},
+ "Port-Name": []string{portName},
+ }
+ res, err := hc.Do(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
+}
diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go
index 3ebd109f8..1b04f4519 100644
--- a/ipn/localapi/localapi.go
+++ b/ipn/localapi/localapi.go
@@ -67,6 +67,7 @@ var handler = map[string]localAPIHandler{
"login-interactive": (*Handler).serveLoginInteractive,
"logout": (*Handler).serveLogout,
"metrics": (*Handler).serveMetrics,
+ "open-bidi-pipe": (*Handler).serveOpenBidiPipe,
"ping": (*Handler).servePing,
"prefs": (*Handler).servePrefs,
"profile": (*Handler).serveProfile,
@@ -764,8 +765,16 @@ func (h *Handler) serveDial(w http.ResponseWriter, r *http.Request) {
return
}
- addr := net.JoinHostPort(hostStr, portStr)
- outConn, err := h.b.Dialer().UserDial(r.Context(), "tcp", addr)
+ dial := func() (net.Conn, error) {
+ if strings.HasPrefix(portStr, "tailpipe-") {
+ // hostStr is expected to be a Tailscale IP at this point.
+ return h.b.DialTailpipe(r.Context(), hostStr, portStr)
+ }
+ addr := net.JoinHostPort(hostStr, portStr)
+ return h.b.Dialer().UserDial(r.Context(), "tcp", addr)
+ }
+
+ outConn, err := dial()
if err != nil {
http.Error(w, "dial failure: "+err.Error(), http.StatusBadGateway)
return
@@ -933,6 +942,29 @@ func (h *Handler) serveTKAModify(w http.ResponseWriter, r *http.Request) {
w.Write(j)
}
+func (h *Handler) serveOpenBidiPipe(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "use POST", http.StatusMethodNotAllowed)
+ return
+ }
+ name := r.FormValue("name")
+ if name != "" && !h.PermitWrite {
+ http.Error(w, "naming a bidi pipe requires write access", http.StatusForbidden)
+ return
+ }
+
+ w.Header().Set("Foo", "bar")
+ w.WriteHeader(http.StatusProcessing) // informational code 102
+
+ time.Sleep(time.Second)
+
+ w.Header().Set("Foo", "baz")
+ w.WriteHeader(http.StatusProcessing) // informational code 102
+
+ w.Header()["Foo"] = nil
+ io.WriteString(w, "the body")
+}
+
func defBool(a string, def bool) bool {
if a == "" {
return def
diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go
index ab254a57e..11c04d45c 100644
--- a/tailcfg/tailcfg.go
+++ b/tailcfg/tailcfg.go
@@ -1647,6 +1647,8 @@ const (
CapabilityDebugPeer = "https://tailscale.com/cap/debug-peer"
// CapabilityWakeOnLAN grants the ability to send a Wake-On-LAN packet.
CapabilityWakeOnLAN = "https://tailscale.com/cap/wake-on-lan"
+
+ CapabilityTailpipeTarget = "https://tailscale.com/cap/tailpipe-target"
)
// SetDNSRequest is a request to add a DNS record.