summaryrefslogtreecommitdiffhomepage
path: root/ipn
diff options
context:
space:
mode:
Diffstat (limited to 'ipn')
-rw-r--r--ipn/ipnlocal/tailpipe.go104
-rw-r--r--ipn/localapi/localapi.go36
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