diff options
Diffstat (limited to 'ipn')
| -rw-r--r-- | ipn/ipnlocal/tailpipe.go | 104 | ||||
| -rw-r--r-- | ipn/localapi/localapi.go | 36 |
2 files changed, 138 insertions, 2 deletions
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 |
