// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause //go:build !ts_omit_taildrop package ipnlocal import ( "cmp" "context" "errors" "io" "maps" "slices" "strings" "tailscale.com/client/tailscale/apitype" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" "tailscale.com/taildrop" "tailscale.com/tstime" "tailscale.com/types/empty" "tailscale.com/util/set" ) func init() { hookSetNotifyFilesWaitingLocked = (*LocalBackend).setNotifyFilesWaitingLocked hookSetPeerStatusTaildropTargetLocked = (*LocalBackend).setPeerStatusTaildropTargetLocked } type taildrop_Manager = taildrop.Manager func (b *LocalBackend) newTaildropManager(fileRoot string) *taildrop.Manager { // TODO(bradfitz): move all this to an ipnext so ipnlocal doesn't need to depend // on taildrop at all. if fileRoot == "" { b.logf("no Taildrop directory configured") } return taildrop.ManagerOptions{ Logf: b.logf, Clock: tstime.DefaultClock{Clock: b.clock}, State: b.store, Dir: fileRoot, DirectFileMode: b.directFileRoot != "", SendFileNotify: b.sendFileNotify, }.New() } func (b *LocalBackend) sendFileNotify() { var n ipn.Notify b.mu.Lock() for _, wakeWaiter := range b.fileWaiters { wakeWaiter() } apiSrv := b.peerAPIServer if apiSrv == nil { b.mu.Unlock() return } n.IncomingFiles = apiSrv.taildrop.IncomingFiles() b.mu.Unlock() b.send(n) } // TaildropManager returns the taildrop manager for this backend. // // TODO(bradfitz): as of 2025-04-15, this is a temporary method during // refactoring; the plan is for all taildrop code to leave the ipnlocal package // and move to an extension. Baby steps. func (b *LocalBackend) TaildropManager() (*taildrop.Manager, error) { b.mu.Lock() ps := b.peerAPIServer b.mu.Unlock() if ps == nil { return nil, errors.New("no peer API server initialized") } if ps.taildrop == nil { return nil, errors.New("no taildrop manager initialized") } return ps.taildrop, nil } func (b *LocalBackend) taildropOrNil() *taildrop.Manager { b.mu.Lock() ps := b.peerAPIServer b.mu.Unlock() if ps == nil { return nil } return ps.taildrop } func (b *LocalBackend) setNotifyFilesWaitingLocked(n *ipn.Notify) { if ps := b.peerAPIServer; ps != nil { if ps.taildrop.HasFilesWaiting() { n.FilesWaiting = &empty.Message{} } } } func (b *LocalBackend) setPeerStatusTaildropTargetLocked(ps *ipnstate.PeerStatus, p tailcfg.NodeView) { ps.TaildropTarget = b.taildropTargetStatus(p) } func (b *LocalBackend) removeFileWaiter(handle set.Handle) { b.mu.Lock() defer b.mu.Unlock() delete(b.fileWaiters, handle) } func (b *LocalBackend) addFileWaiter(wakeWaiter context.CancelFunc) set.Handle { b.mu.Lock() defer b.mu.Unlock() return b.fileWaiters.Add(wakeWaiter) } func (b *LocalBackend) WaitingFiles() ([]apitype.WaitingFile, error) { return b.taildropOrNil().WaitingFiles() } // AwaitWaitingFiles is like WaitingFiles but blocks while ctx is not done, // waiting for any files to be available. // // On return, exactly one of the results will be non-empty or non-nil, // respectively. func (b *LocalBackend) AwaitWaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) { if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 { return ff, err } for { gotFile, gotFileCancel := context.WithCancel(context.Background()) defer gotFileCancel() handle := b.addFileWaiter(gotFileCancel) defer b.removeFileWaiter(handle) // Now that we've registered ourselves, check again, in case // of race. Otherwise there's a small window where we could // miss a file arrival and wait forever. if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 { return ff, err } select { case <-gotFile.Done(): if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 { return ff, err } case <-ctx.Done(): return nil, ctx.Err() } } } func (b *LocalBackend) DeleteFile(name string) error { return b.taildropOrNil().DeleteFile(name) } func (b *LocalBackend) OpenFile(name string) (rc io.ReadCloser, size int64, err error) { return b.taildropOrNil().OpenFile(name) } // HasCapFileSharing reports whether the current node has the file // sharing capability enabled. func (b *LocalBackend) HasCapFileSharing() bool { // TODO(bradfitz): remove this method and all Taildrop/Taildrive // references from LocalBackend as part of tailscale/tailscale#12614. b.mu.Lock() defer b.mu.Unlock() return b.capFileSharing } // FileTargets lists nodes that the current node can send files to. func (b *LocalBackend) FileTargets() ([]*apitype.FileTarget, error) { var ret []*apitype.FileTarget b.mu.Lock() defer b.mu.Unlock() nm := b.netMap if b.state != ipn.Running || nm == nil { return nil, errors.New("not connected to the tailnet") } if !b.capFileSharing { return nil, errors.New("file sharing not enabled by Tailscale admin") } for _, p := range b.peers { if !b.peerIsTaildropTargetLocked(p) { continue } if p.Hostinfo().OS() == "tvOS" { continue } peerAPI := peerAPIBase(b.netMap, p) if peerAPI == "" { continue } ret = append(ret, &apitype.FileTarget{ Node: p.AsStruct(), PeerAPIURL: peerAPI, }) } slices.SortFunc(ret, func(a, b *apitype.FileTarget) int { return cmp.Compare(a.Node.Name, b.Node.Name) }) return ret, nil } func (b *LocalBackend) taildropTargetStatus(p tailcfg.NodeView) ipnstate.TaildropTargetStatus { if b.state != ipn.Running { return ipnstate.TaildropTargetIpnStateNotRunning } if b.netMap == nil { return ipnstate.TaildropTargetNoNetmapAvailable } if !b.capFileSharing { return ipnstate.TaildropTargetMissingCap } if !p.Online().Get() { return ipnstate.TaildropTargetOffline } if !p.Valid() { return ipnstate.TaildropTargetNoPeerInfo } if b.netMap.User() != p.User() { // Different user must have the explicit file sharing target capability if p.Addresses().Len() == 0 || !b.peerHasCapLocked(p.Addresses().At(0).Addr(), tailcfg.PeerCapabilityFileSharingTarget) { return ipnstate.TaildropTargetOwnedByOtherUser } } if p.Hostinfo().OS() == "tvOS" { return ipnstate.TaildropTargetUnsupportedOS } if peerAPIBase(b.netMap, p) == "" { return ipnstate.TaildropTargetNoPeerAPI } return ipnstate.TaildropTargetAvailable } // peerIsTaildropTargetLocked reports whether p is a valid Taildrop file // recipient from this node according to its ownership and the capabilities in // the netmap. // // b.mu must be locked. func (b *LocalBackend) peerIsTaildropTargetLocked(p tailcfg.NodeView) bool { if b.netMap == nil || !p.Valid() { return false } if b.netMap.User() == p.User() { return true } if p.Addresses().Len() > 0 && b.peerHasCapLocked(p.Addresses().At(0).Addr(), tailcfg.PeerCapabilityFileSharingTarget) { // Explicitly noted in the netmap ACL caps as a target. return true } return false } // UpdateOutgoingFiles updates b.outgoingFiles to reflect the given updates and // sends an ipn.Notify with the full list of outgoingFiles. func (b *LocalBackend) UpdateOutgoingFiles(updates map[string]*ipn.OutgoingFile) { b.mu.Lock() if b.outgoingFiles == nil { b.outgoingFiles = make(map[string]*ipn.OutgoingFile, len(updates)) } maps.Copy(b.outgoingFiles, updates) outgoingFiles := make([]*ipn.OutgoingFile, 0, len(b.outgoingFiles)) for _, file := range b.outgoingFiles { outgoingFiles = append(outgoingFiles, file) } b.mu.Unlock() slices.SortFunc(outgoingFiles, func(a, b *ipn.OutgoingFile) int { t := a.Started.Compare(b.Started) if t != 0 { return t } return strings.Compare(a.Name, b.Name) }) b.send(ipn.Notify{OutgoingFiles: outgoingFiles}) }