diff options
| author | Percy Wegmann <percy@tailscale.com> | 2023-12-21 15:57:01 -0600 |
|---|---|---|
| committer | Percy Wegmann <percy@tailscale.com> | 2024-02-02 12:33:48 -0600 |
| commit | 857cef70c97fe019ea24f7b4f24252f4746ebcc3 (patch) | |
| tree | 257a7ec87af6ad29c06cdfe0e8774259f5e83cc3 /ipn/ipnlocal | |
| parent | 60657ac83f415555c19027b0b18c0fe2d15bf40a (diff) | |
| download | tailscale-flyingsquirrel_bak.tar.xz tailscale-flyingsquirrel_bak.zip | |
tailfs: initial implementationflyingsquirrel_bak
Implemented WebDAV-based core of Tailfs
Updates tailscale/corp#16827
Signed-off-by: Percy Wegmann <percy@tailscale.com>
Diffstat (limited to 'ipn/ipnlocal')
| -rw-r--r-- | ipn/ipnlocal/local.go | 69 | ||||
| -rw-r--r-- | ipn/ipnlocal/local_test.go | 2 | ||||
| -rw-r--r-- | ipn/ipnlocal/peerapi.go | 50 | ||||
| -rw-r--r-- | ipn/ipnlocal/serve.go | 2 | ||||
| -rw-r--r-- | ipn/ipnlocal/tailfs.go | 274 |
5 files changed, 393 insertions, 4 deletions
diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 706ca524b..9c215f0a2 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -67,6 +67,7 @@ import ( "tailscale.com/syncs" "tailscale.com/tailcfg" "tailscale.com/taildrop" + "tailscale.com/tailfs" "tailscale.com/tka" "tailscale.com/tsd" "tailscale.com/tstime" @@ -287,6 +288,9 @@ type LocalBackend struct { serveListeners map[netip.AddrPort]*localListener // listeners for local serve traffic serveProxyHandlers sync.Map // string (HTTPHandler.Proxy) => *reverseProxy + tailfsListeners map[netip.AddrPort]*localListener // listeners for local tailfs traffic + tailfsForRemote tailfs.ForRemote + // statusLock must be held before calling statusChanged.Wait() or // statusChanged.Broadcast(). statusLock sync.Mutex @@ -428,6 +432,15 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo } } + // initialize Tailfs shares from saved state + b.mu.Lock() + b.tailfsForRemote = tailfs.NewFileSystemForRemote(logf) + shares, err := b.tailfsGetSharesLocked() + b.mu.Unlock() + if err == nil && len(shares) > 0 { + b.tailfsForRemote.SetShares(shares) + } + return b, nil } @@ -915,6 +928,7 @@ func (b *LocalBackend) WhoIs(ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg. var zero tailcfg.NodeView b.mu.Lock() defer b.mu.Unlock() + nid, ok := b.nodeByAddr[ipp.Addr()] if !ok { var ip netip.Addr @@ -2253,7 +2267,7 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa b.mu.Lock() b.activeWatchSessions.Add(sessionID) - const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap + const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyInitialTailfsShares if mask&initialBits != 0 { ini = &ipn.Notify{Version: version.Long()} if mask&ipn.NotifyInitialState != 0 { @@ -2269,6 +2283,17 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa if mask&ipn.NotifyInitialNetMap != 0 { ini.NetMap = b.netMap } + if mask&ipn.NotifyInitialTailfsShares != 0 && b.tailfsSharingEnabledLocked() { + shares, err := b.tailfsGetSharesLocked() + if err != nil { + b.logf("unable to notify initial tailfs shares: %v", err) + } else { + ini.TailfsShares = make(map[string]string, len(shares)) + for _, share := range shares { + ini.TailfsShares[share.Name] = share.Path + } + } + } } handle := b.notifyWatchers.Add(&watchSession{ch, sessionID}) @@ -3307,6 +3332,14 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c if dst.Port() == webClientPort && b.ShouldRunWebClient() { return b.handleWebClientConn, opts } + if dst.Port() == TailfsLocalPort { + fs, ok := b.sys.TailfsForLocal.GetOK() + if ok { + return func(conn net.Conn) error { + return fs.HandleConn(conn, conn.RemoteAddr()) + }, opts + } + } if port, ok := b.GetPeerAPIPort(dst.Addr()); ok && dst.Port() == port { return func(c net.Conn) error { b.handlePeerAPIConn(src, dst, c) @@ -4601,6 +4634,11 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) { delete(b.nodeByAddr, k) } } + + if b.tailfsSharingEnabledLocked() { + b.updateTailfsPeersLocked(nm) + b.tailfsNotifyCurrentSharesLocked() + } } func (b *LocalBackend) updatePeersFromNetmapLocked(nm *netmap.NetworkMap) { @@ -4608,14 +4646,17 @@ func (b *LocalBackend) updatePeersFromNetmapLocked(nm *netmap.NetworkMap) { b.peers = nil return } + // First pass, mark everything unwanted. for k := range b.peers { b.peers[k] = tailcfg.NodeView{} } + // Second pass, add everything wanted. for _, p := range nm.Peers { mak.Set(&b.peers, p.ID(), p) } + // Third pass, remove deleted things. for k, v := range b.peers { if !v.Valid() { @@ -4624,6 +4665,28 @@ func (b *LocalBackend) updatePeersFromNetmapLocked(nm *netmap.NetworkMap) { } } +// tailfsTransport is an http.RoundTripper that uses the latest value of +// b.Dialer().PeerAPITransport() for each round trip and imposes a short +// dial timeout to avoid hanging on connecting to offline/unreachable hosts. +type tailfsTransport struct { + b *LocalBackend +} + +func (t *tailfsTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // dialTimeout is fairly aggressive to avoid hangs on contacting offline or + // unreachable hosts. + dialTimeout := 1 * time.Second // TODO(oxtoacart): tune this + + tr := t.b.Dialer().PeerAPITransport().Clone() + dialContext := tr.DialContext + tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + ctxWithTimeout, cancel := context.WithTimeout(ctx, dialTimeout) + defer cancel() + return dialContext(ctxWithTimeout, network, addr) + } + return tr.RoundTrip(req) +} + // setDebugLogsByCapabilityLocked sets debug logging based on the self node's // capabilities in the provided NetMap. func (b *LocalBackend) setDebugLogsByCapabilityLocked(nm *netmap.NetworkMap) { @@ -4696,6 +4759,10 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn. } } + if !b.sys.IsNetstack() { + b.updateTailfsListenersLocked() + } + b.reloadServeConfigLocked(prefs) if b.serveConfig.Valid() { servePorts := make([]uint16, 0, 3) diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 8c8b68a87..a3cb7e213 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -803,7 +803,7 @@ func TestWatchNotificationsCallbacks(t *testing.T) { // tests LocalBackend.updateNetmapDeltaLocked func TestUpdateNetmapDelta(t *testing.T) { - var b LocalBackend + b := newTestLocalBackend(t) if b.updateNetmapDeltaLocked(nil) { t.Errorf("updateNetmapDeltaLocked() = true, want false with nil netmap") } diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index 176880302..ac41893eb 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/ipn/ipnlocal/peerapi.go @@ -38,12 +38,17 @@ import ( "tailscale.com/net/sockstats" "tailscale.com/tailcfg" "tailscale.com/taildrop" + "tailscale.com/tailfs" "tailscale.com/types/views" "tailscale.com/util/clientmetric" "tailscale.com/util/httphdr" "tailscale.com/wgengine/filter" ) +const ( + tailfsPrefix = "/v0/tailfs" +) + var initListenConfig func(*net.ListenConfig, netip.Addr, *interfaces.State, string) error // addH2C is non-nil on platforms where we want to add H2C @@ -317,6 +322,10 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.handleDNSQuery(w, r) return } + if strings.HasPrefix(r.URL.Path, tailfsPrefix) { + h.handleServeTailfs(w, r) + return + } switch r.URL.Path { case "/v0/goroutines": h.handleServeGoroutines(w, r) @@ -626,7 +635,11 @@ func (h *peerAPIHandler) canIngress() bool { } func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool { - return h.ps.b.PeerCaps(h.remoteAddr.Addr()).HasCapability(wantCap) + return h.peerCaps().HasCapability(wantCap) +} + +func (h *peerAPIHandler) peerCaps() tailcfg.PeerCapMap { + return h.ps.b.PeerCaps(h.remoteAddr.Addr()) } func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) { @@ -1090,6 +1103,41 @@ func writePrettyDNSReply(w io.Writer, res []byte) (err error) { return nil } +func (h *peerAPIHandler) handleServeTailfs(w http.ResponseWriter, r *http.Request) { + if !h.ps.b.TailfsSharingEnabled() { + http.Error(w, "tailfs not enabled", http.StatusNotFound) + return + } + + capsMap := h.peerCaps() + tailfsCaps, ok := capsMap[tailcfg.PeerCapabilityTailfs] + if !ok { + http.Error(w, "tailfs not permitted", http.StatusForbidden) + return + } + + rawPerms := make([][]byte, 0, len(tailfsCaps)) + for _, cap := range tailfsCaps { + rawPerms = append(rawPerms, []byte(cap)) + } + + p, err := tailfs.ParsePermissions(rawPerms) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + h.ps.b.mu.Lock() + fs := h.ps.b.tailfsForRemote + h.ps.b.mu.Unlock() + if fs == nil { + http.Error(w, "tailfs not enabled", http.StatusNotFound) + return + } + r.URL.Path = strings.TrimPrefix(r.URL.Path, tailfsPrefix) + fs.ServeHTTP(p, w, r) +} + // newFakePeerAPIListener creates a new net.Listener that acts like // it's listening on the provided IP address and on TCP port 1. // diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 3d7f32bff..d1ed1ef8a 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -62,7 +62,7 @@ type serveHTTPContext struct { // // This is not used in userspace-networking mode. // -// localListener is used by tailscale serve (TCP only) as well as the built-in web client. +// localListener is used by tailscale serve (TCP only), the built-in web client and tailfs. // Most serve traffic and peer traffic for the web client are intercepted by netstack. // This listener exists purely for connections from the machine itself, as that goes via the kernel, // so we need to be in the kernel's listening/routing tables. diff --git a/ipn/ipnlocal/tailfs.go b/ipn/ipnlocal/tailfs.go new file mode 100644 index 000000000..1eda8b5cb --- /dev/null +++ b/ipn/ipnlocal/tailfs.go @@ -0,0 +1,274 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package ipnlocal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/netip" + "os" + "time" + + "tailscale.com/ipn" + "tailscale.com/logtail/backoff" + "tailscale.com/tailcfg" + "tailscale.com/tailfs" + "tailscale.com/types/logger" + "tailscale.com/types/netmap" + "tailscale.com/util/mak" +) + +const ( + // TailfsLocalPort is the port on which the Tailfs listens for location + // connections on quad 100. + TailfsLocalPort = 8080 + + tailfsSharesStateKey = ipn.StateKey("_tailfs-shares") +) + +// TailfsSharingEnabled indicates whether sharing to remote nodes via tailfs is +// enabled. This is currently based on checking for the tailfs:share node +// attribute. +func (b *LocalBackend) TailfsSharingEnabled() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.tailfsSharingEnabledLocked() +} + +func (b *LocalBackend) tailfsSharingEnabledLocked() bool { + return b.netMap != nil && b.netMap.SelfNode.HasCap(tailcfg.NodeAttrsTailfsSharingEnabled) +} + +// TailfsSetFileServerAddr tells tailfs to use the given address for connecting +// to the tailfs.FileServer that's exposing local files as an unprivileged +// user. +func (b *LocalBackend) TailfsSetFileServerAddr(addr string) error { + b.mu.Lock() + fs := b.tailfsForRemote + b.mu.Unlock() + if fs == nil { + return errors.New("tailfs not enabled") + } + + fs.SetFileServerAddr(addr) + return nil +} + +// TailfsAddShare adds/edits a share. +func (b *LocalBackend) TailfsAddShare(share *tailfs.Share) error { + b.mu.Lock() + fs := b.tailfsForRemote + b.mu.Unlock() + if fs == nil { + return errors.New("tailfs not enabled") + } + + b.mu.Lock() + shares, err := b.tailfsAddShareLocked(fs, share) + b.mu.Unlock() + if err != nil { + return err + } + + b.tailfsNotifyShares(shares) + return nil +} + +func (b *LocalBackend) tailfsAddShareLocked(fs tailfs.ForRemote, share *tailfs.Share) (map[string]*tailfs.Share, error) { + shares, err := b.tailfsGetSharesLocked() + if err != nil { + return nil, err + } + shares[share.Name] = share + data, err := json.Marshal(shares) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + err = b.store.WriteState(tailfsSharesStateKey, data) + if err != nil { + return nil, fmt.Errorf("write state: %w", err) + } + fs.SetShares(shares) + return shares, nil +} + +// TailfsRemoveShare removes the named share. +func (b *LocalBackend) TailfsRemoveShare(name string) error { + b.mu.Lock() + fs := b.tailfsForRemote + b.mu.Unlock() + if fs == nil { + return errors.New("tailfs not enabled") + } + + b.mu.Lock() + shares, err := b.tailfsRemoveShareLocked(fs, name) + b.mu.Unlock() + if err != nil { + return err + } + + b.tailfsNotifyShares(shares) + return nil +} + +func (b *LocalBackend) tailfsRemoveShareLocked(fs tailfs.ForRemote, name string) (map[string]*tailfs.Share, error) { + shares, err := b.tailfsGetSharesLocked() + if err != nil { + return nil, err + } + _, shareExists := shares[name] + if !shareExists { + return nil, os.ErrNotExist + } + delete(shares, name) + data, err := json.Marshal(shares) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + err = b.store.WriteState(tailfsSharesStateKey, data) + if err != nil { + return nil, fmt.Errorf("write state: %w", err) + } + fs.SetShares(shares) + return shares, nil +} + +// tailfsNotifyShares notifies IPN bus listeners (e.g. Mac Application process) +// about the latest set of shares. +func (b *LocalBackend) tailfsNotifyShares(shares map[string]*tailfs.Share) { + sharesMap := make(map[string]string, len(shares)) + for _, share := range shares { + sharesMap[share.Name] = share.Path + } + b.send(ipn.Notify{TailfsShares: sharesMap}) +} + +// tailfsNotifyCurrentSharesLocked sends an ipn.Notify with the current set of +// tailfs shares. +func (b *LocalBackend) tailfsNotifyCurrentSharesLocked() { + shares, err := b.tailfsGetSharesLocked() + if err != nil { + b.logf("error notifying current tailfs shares: %v", err) + return + } + // Do the below on a goroutine to avoid deadlocking on b.mu in b.send(). + go b.tailfsNotifyShares(shares) +} + +// TailfsGetShares() returns the current set of shares from the state store. +func (b *LocalBackend) TailfsGetShares() (map[string]*tailfs.Share, error) { + b.mu.Lock() + defer b.mu.Unlock() + + return b.tailfsGetSharesLocked() +} + +func (b *LocalBackend) tailfsGetSharesLocked() (map[string]*tailfs.Share, error) { + data, err := b.store.ReadState(tailfsSharesStateKey) + if err != nil { + if errors.Is(err, ipn.ErrStateNotExist) { + return make(map[string]*tailfs.Share), nil + } else { + return nil, fmt.Errorf("read state: %w", err) + } + } + + var shares map[string]*tailfs.Share + err = json.Unmarshal(data, &shares) + if err != nil { + return nil, fmt.Errorf("unmarshal: %w", err) + } + + return shares, nil +} + +// updateTailfsListenersLocked creates listeners on the local Tailfs port. +// This is needed to properly route local traffic when using kernel networking +// mode. +func (b *LocalBackend) updateTailfsListenersLocked() { + if b.netMap == nil { + return + } + + addrs := b.netMap.GetAddresses() + for i := range addrs.LenIter() { + if fs, ok := b.sys.TailfsForLocal.GetOK(); ok { + addrPort := netip.AddrPortFrom(addrs.At(i).Addr(), TailfsLocalPort) + if _, ok := b.tailfsListeners[addrPort]; ok { + continue // already listening + } + + sl := b.newTailfsListener(context.Background(), fs, addrPort, b.logf) + mak.Set(&b.tailfsListeners, addrPort, sl) + + go sl.Run() + } + } +} + +// newTailfsListener returns a listener for local connections to a tailfs +// WebDAV FileSystem. +func (b *LocalBackend) newTailfsListener(ctx context.Context, fs tailfs.ForLocal, ap netip.AddrPort, logf logger.Logf) *localListener { + ctx, cancel := context.WithCancel(ctx) + return &localListener{ + b: b, + ap: ap, + ctx: ctx, + cancel: cancel, + logf: logf, + + handler: func(conn net.Conn) error { + return fs.HandleConn(conn, conn.RemoteAddr()) + }, + bo: backoff.NewBackoff(fmt.Sprintf("tailfs-listener-%d", ap.Port()), logf, 30*time.Second), + } +} + +// updateTailfsPeersLocked sets all applicable peers from the netmap as tailfs +// remotes. +func (b *LocalBackend) updateTailfsPeersLocked(nm *netmap.NetworkMap) { + fs, ok := b.sys.TailfsForLocal.GetOK() + if !ok { + return + } + + tailfsRemotes := make([]*tailfs.Remote, 0, len(nm.Peers)) + for _, p := range nm.Peers { + peerID := p.ID() + url := fmt.Sprintf("%s/%s", peerAPIBase(nm, p), tailfsPrefix[1:]) + tailfsRemotes = append(tailfsRemotes, &tailfs.Remote{ + Name: p.DisplayName(false), + URL: url, + Available: func() bool { + // TODO(oxtoacart): need to figure out a performant and reliable way to only + // show the peers that have shares to which we have access + // This will require work on the control server to transmit the inverse + // of the "tailscale.com/cap/tailfs" capability. + // For now, at least limit it only to nodes that are online. + // Note, we have to iterate the latest netmap because the peer we got from the first iteration may not be it + b.mu.Lock() + latestNetMap := b.netMap + b.mu.Unlock() + + for _, candidate := range latestNetMap.Peers { + if candidate.ID() == peerID { + online := candidate.Online() + // TODO(oxtoacart): for some reason, this correctly + // catches when a node goes from offline to online, + // but not the other way around... + return online != nil && *online + } + } + + // peer not found, must not be available + return false + }, + }) + } + fs.SetRemotes(b.netMap.Domain, tailfsRemotes, &tailfsTransport{b: b}) +} |
