// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause //go:build !ts_omit_drive package ipnlocal import ( "errors" "fmt" "io" "net/http" "net/netip" "os" "slices" "tailscale.com/drive" "tailscale.com/ipn" "tailscale.com/tailcfg" "tailscale.com/types/logger" "tailscale.com/types/netmap" "tailscale.com/types/views" "tailscale.com/util/httpm" ) func init() { hookSetNetMapLockedDrive.Set(setNetMapLockedDrive) } func setNetMapLockedDrive(b *LocalBackend, nm *netmap.NetworkMap) { b.updateDrivePeersLocked(nm) b.driveNotifyCurrentSharesLocked() } // DriveSetServerAddr tells Taildrive to use the given address for connecting // to the drive.FileServer that's exposing local files as an unprivileged // user. func (b *LocalBackend) DriveSetServerAddr(addr string) error { fs, ok := b.sys.DriveForRemote.GetOK() if !ok { return drive.ErrDriveNotEnabled } fs.SetFileServerAddr(addr) return nil } // DriveSetShare adds the given share if no share with that name exists, or // replaces the existing share if one with the same name already exists. To // avoid potential incompatibilities across file systems, share names are // limited to alphanumeric characters and the underscore _. func (b *LocalBackend) DriveSetShare(share *drive.Share) error { var err error share.Name, err = drive.NormalizeShareName(share.Name) if err != nil { return err } b.mu.Lock() shares, err := b.driveSetShareLocked(share) b.mu.Unlock() if err != nil { return err } b.driveNotifyShares(shares) return nil } func (b *LocalBackend) driveSetShareLocked(share *drive.Share) (views.SliceView[*drive.Share, drive.ShareView], error) { existingShares := b.pm.prefs.DriveShares() fs, ok := b.sys.DriveForRemote.GetOK() if !ok { return existingShares, drive.ErrDriveNotEnabled } addedShare := false var shares []*drive.Share for _, existing := range existingShares.All() { if existing.Name() != share.Name { if !addedShare && existing.Name() > share.Name { // Add share in order shares = append(shares, share) addedShare = true } shares = append(shares, existing.AsStruct()) } } if !addedShare { shares = append(shares, share) } err := b.driveSetSharesLocked(shares) if err != nil { return existingShares, err } fs.SetShares(shares) return b.pm.prefs.DriveShares(), nil } // DriveRenameShare renames the share at old name to new name. To avoid // potential incompatibilities across file systems, the new share name is // limited to alphanumeric characters and the underscore _. // Any of the following will result in an error. // - no share found under old name // - new share name contains disallowed characters // - share already exists under new name func (b *LocalBackend) DriveRenameShare(oldName, newName string) error { var err error newName, err = drive.NormalizeShareName(newName) if err != nil { return err } b.mu.Lock() shares, err := b.driveRenameShareLocked(oldName, newName) b.mu.Unlock() if err != nil { return err } b.driveNotifyShares(shares) return nil } func (b *LocalBackend) driveRenameShareLocked(oldName, newName string) (views.SliceView[*drive.Share, drive.ShareView], error) { existingShares := b.pm.prefs.DriveShares() fs, ok := b.sys.DriveForRemote.GetOK() if !ok { return existingShares, drive.ErrDriveNotEnabled } found := false var shares []*drive.Share for _, existing := range existingShares.All() { if existing.Name() == newName { return existingShares, os.ErrExist } if existing.Name() == oldName { share := existing.AsStruct() share.Name = newName shares = append(shares, share) found = true } else { shares = append(shares, existing.AsStruct()) } } if !found { return existingShares, os.ErrNotExist } slices.SortFunc(shares, drive.CompareShares) err := b.driveSetSharesLocked(shares) if err != nil { return existingShares, err } fs.SetShares(shares) return b.pm.prefs.DriveShares(), nil } // DriveRemoveShare removes the named share. Share names are forced to // lowercase. func (b *LocalBackend) DriveRemoveShare(name string) error { // Force all share names to lowercase to avoid potential incompatibilities // with clients that don't support case-sensitive filenames. var err error name, err = drive.NormalizeShareName(name) if err != nil { return err } b.mu.Lock() shares, err := b.driveRemoveShareLocked(name) b.mu.Unlock() if err != nil { return err } b.driveNotifyShares(shares) return nil } func (b *LocalBackend) driveRemoveShareLocked(name string) (views.SliceView[*drive.Share, drive.ShareView], error) { existingShares := b.pm.prefs.DriveShares() fs, ok := b.sys.DriveForRemote.GetOK() if !ok { return existingShares, drive.ErrDriveNotEnabled } found := false var shares []*drive.Share for _, existing := range existingShares.All() { if existing.Name() != name { shares = append(shares, existing.AsStruct()) } else { found = true } } if !found { return existingShares, os.ErrNotExist } err := b.driveSetSharesLocked(shares) if err != nil { return existingShares, err } fs.SetShares(shares) return b.pm.prefs.DriveShares(), nil } func (b *LocalBackend) driveSetSharesLocked(shares []*drive.Share) error { prefs := b.pm.prefs.AsStruct() prefs.ApplyEdits(&ipn.MaskedPrefs{ Prefs: ipn.Prefs{ DriveShares: shares, }, DriveSharesSet: true, }) return b.pm.setPrefsNoPermCheck(prefs.View()) } // driveNotifyShares notifies IPN bus listeners (e.g. Mac Application process) // about the latest list of shares, if and only if the shares have changed since // the last time we notified. func (b *LocalBackend) driveNotifyShares(shares views.SliceView[*drive.Share, drive.ShareView]) { b.lastNotifiedDriveSharesMu.Lock() defer b.lastNotifiedDriveSharesMu.Unlock() if b.lastNotifiedDriveShares != nil && driveShareViewsEqual(b.lastNotifiedDriveShares, shares) { // shares are unchanged since last notification, don't bother notifying return } b.lastNotifiedDriveShares = &shares // Ensures shares is not nil to distinguish "no shares" from "not notifying shares" if shares.IsNil() { shares = views.SliceOfViews(make([]*drive.Share, 0)) } b.send(ipn.Notify{DriveShares: shares}) } // driveNotifyCurrentSharesLocked sends an ipn.Notify if the current set of // shares has changed since the last notification. func (b *LocalBackend) driveNotifyCurrentSharesLocked() { var shares views.SliceView[*drive.Share, drive.ShareView] if b.DriveSharingEnabled() { // Only populate shares if sharing is enabled. shares = b.pm.prefs.DriveShares() } // Do the below on a goroutine to avoid deadlocking on b.mu in b.send(). go b.driveNotifyShares(shares) } func driveShareViewsEqual(a *views.SliceView[*drive.Share, drive.ShareView], b views.SliceView[*drive.Share, drive.ShareView]) bool { if a == nil { return false } if a.Len() != b.Len() { return false } for i := range a.Len() { if !drive.ShareViewsEqual(a.At(i), b.At(i)) { return false } } return true } // DriveGetShares gets the current list of Taildrive shares, sorted by name. func (b *LocalBackend) DriveGetShares() views.SliceView[*drive.Share, drive.ShareView] { b.mu.Lock() defer b.mu.Unlock() return b.pm.prefs.DriveShares() } // updateDrivePeersLocked sets all applicable peers from the netmap as Taildrive // remotes. func (b *LocalBackend) updateDrivePeersLocked(nm *netmap.NetworkMap) { fs, ok := b.sys.DriveForLocal.GetOK() if !ok { return } var driveRemotes []*drive.Remote if b.DriveAccessEnabled() { // Only populate peers if access is enabled, otherwise leave blank. driveRemotes = b.driveRemotesFromPeers(nm) } fs.SetRemotes(nm.Domain, driveRemotes, b.newDriveTransport()) } func (b *LocalBackend) driveRemotesFromPeers(nm *netmap.NetworkMap) []*drive.Remote { b.logf("[v1] taildrive: setting up drive remotes from peers") driveRemotes := make([]*drive.Remote, 0, len(nm.Peers)) for _, p := range nm.Peers { peer := p peerID := peer.ID() peerKey := peer.Key().ShortString() b.logf("[v1] taildrive: appending remote for peer %s", peerKey) driveRemotes = append(driveRemotes, &drive.Remote{ Name: p.DisplayName(false), URL: func() string { url := fmt.Sprintf("%s/%s", b.currentNode().PeerAPIBase(peer), taildrivePrefix[1:]) b.logf("[v2] taildrive: url for peer %s: %s", peerKey, url) return url }, Available: func() bool { // Peers are available to Taildrive if: // - They are online // - Their PeerAPI is reachable // - They are allowed to share at least one folder with us cn := b.currentNode() peer, ok := cn.NodeByID(peerID) if !ok { b.logf("[v2] taildrive: Available(): peer %s not found", peerKey) return false } // Exclude offline peers. // TODO(oxtoacart): for some reason, this correctly // catches when a node goes from offline to online, // but not the other way around... // TODO(oxtoacart,nickkhyl): the reason was probably // that we were using netmap.Peers instead of b.peers. // The netmap.Peers slice is not updated in all cases. // It should be fixed now that we use PeerByIDOk. if !peer.Online().Get() { b.logf("[v2] taildrive: Available(): peer %s offline", peerKey) return false } if b.currentNode().PeerAPIBase(peer) == "" { b.logf("[v2] taildrive: Available(): peer %s PeerAPI unreachable", peerKey) return false } // Check that the peer is allowed to share with us. if cn.PeerHasCap(peer, tailcfg.PeerCapabilityTaildriveSharer) { b.logf("[v2] taildrive: Available(): peer %s available", peerKey) return true } b.logf("[v2] taildrive: Available(): peer %s not allowed to share", peerKey) return false }, }) } return driveRemotes } // responseBodyWrapper wraps an io.ReadCloser and stores // the number of bytesRead. type responseBodyWrapper struct { io.ReadCloser logVerbose bool bytesRx int64 bytesTx int64 log logger.Logf method string statusCode int contentType string fileExtension string shareNodeKey string selfNodeKey string contentLength int64 } // logAccess logs the taildrive: access: log line. If the logger is nil, // the log will not be written. func (rbw *responseBodyWrapper) logAccess(err string) { if rbw.log == nil { return } // Some operating systems create and copy lots of 0 length hidden files for // tracking various states. Omit these to keep logs from being too verbose. if rbw.logVerbose || rbw.contentLength > 0 { levelPrefix := "" if rbw.logVerbose { levelPrefix = "[v1] " } rbw.log( "%staildrive: access: %s from %s to %s: status-code=%d ext=%q content-type=%q content-length=%.f tx=%.f rx=%.f err=%q", levelPrefix, rbw.method, rbw.selfNodeKey, rbw.shareNodeKey, rbw.statusCode, rbw.fileExtension, rbw.contentType, roundTraffic(rbw.contentLength), roundTraffic(rbw.bytesTx), roundTraffic(rbw.bytesRx), err) } } // Read implements the io.Reader interface. func (rbw *responseBodyWrapper) Read(b []byte) (int, error) { n, err := rbw.ReadCloser.Read(b) rbw.bytesRx += int64(n) if err != nil && !errors.Is(err, io.EOF) { rbw.logAccess(err.Error()) } return n, err } // Close implements the io.Close interface. func (rbw *responseBodyWrapper) Close() error { err := rbw.ReadCloser.Close() var errStr string if err != nil { errStr = err.Error() } rbw.logAccess(errStr) return err } // driveTransport is an http.RoundTripper that wraps // b.Dialer().PeerAPITransport() with metrics tracking. type driveTransport struct { b *LocalBackend tr *http.Transport } func (b *LocalBackend) newDriveTransport() *driveTransport { return &driveTransport{ b: b, tr: b.Dialer().PeerAPITransport(), } } func (dt *driveTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) { // Some WebDAV clients include origin and refer headers, which peerapi does // not like. Remove them. req.Header.Del("origin") req.Header.Del("referer") bw := &requestBodyWrapper{} if req.Body != nil { bw.ReadCloser = req.Body req.Body = bw } defer func() { contentType := "unknown" if ct := req.Header.Get("Content-Type"); ct != "" { contentType = ct } dt.b.mu.Lock() selfNodeKey := dt.b.currentNode().Self().Key().ShortString() dt.b.mu.Unlock() n, _, ok := dt.b.WhoIs("tcp", netip.MustParseAddrPort(req.URL.Host)) shareNodeKey := "unknown" if ok { shareNodeKey = string(n.Key().ShortString()) } rbw := responseBodyWrapper{ log: dt.b.logf, logVerbose: req.Method != httpm.GET && req.Method != httpm.PUT, // other requests like PROPFIND are quite chatty, so we log those at verbose level method: req.Method, bytesTx: int64(bw.bytesRead), selfNodeKey: selfNodeKey, shareNodeKey: shareNodeKey, contentType: contentType, contentLength: resp.ContentLength, fileExtension: parseDriveFileExtensionForLog(req.URL.Path), statusCode: resp.StatusCode, ReadCloser: resp.Body, } if resp.StatusCode >= 400 { // in case of error response, just log immediately rbw.logAccess("") } else { resp.Body = &rbw } }() return dt.tr.RoundTrip(req) }