ext.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. // Copyright (c) Tailscale Inc & AUTHORS
  2. // SPDX-License-Identifier: BSD-3-Clause
  3. package taildrop
  4. import (
  5. "cmp"
  6. "context"
  7. "errors"
  8. "fmt"
  9. "io"
  10. "maps"
  11. "path/filepath"
  12. "runtime"
  13. "slices"
  14. "strings"
  15. "sync"
  16. "sync/atomic"
  17. "tailscale.com/client/tailscale/apitype"
  18. "tailscale.com/cmd/tailscaled/tailscaledhooks"
  19. "tailscale.com/ipn"
  20. "tailscale.com/ipn/ipnext"
  21. "tailscale.com/ipn/ipnstate"
  22. "tailscale.com/tailcfg"
  23. "tailscale.com/tstime"
  24. "tailscale.com/types/empty"
  25. "tailscale.com/types/logger"
  26. "tailscale.com/util/osshare"
  27. "tailscale.com/util/set"
  28. )
  29. func init() {
  30. ipnext.RegisterExtension("taildrop", newExtension)
  31. if runtime.GOOS == "windows" {
  32. tailscaledhooks.UninstallSystemDaemonWindows.Add(func() {
  33. // Remove file sharing from Windows shell.
  34. osshare.SetFileSharingEnabled(false, logger.Discard)
  35. })
  36. }
  37. }
  38. func newExtension(logf logger.Logf, b ipnext.SafeBackend) (ipnext.Extension, error) {
  39. e := &Extension{
  40. sb: b,
  41. stateStore: b.Sys().StateStore.Get(),
  42. logf: logger.WithPrefix(logf, "taildrop: "),
  43. }
  44. e.setPlatformDefaultDirectFileRoot()
  45. return e, nil
  46. }
  47. // Extension implements Taildrop.
  48. type Extension struct {
  49. logf logger.Logf
  50. sb ipnext.SafeBackend
  51. stateStore ipn.StateStore
  52. host ipnext.Host // from Init
  53. // directFileRoot, if non-empty, means to write received files
  54. // directly to this directory, without staging them in an
  55. // intermediate buffered directory for "pick-up" later. If
  56. // empty, the files are received in a daemon-owned location
  57. // and the localapi is used to enumerate, download, and delete
  58. // them. This is used on macOS where the GUI lifetime is the
  59. // same as the Network Extension lifetime and we can thus avoid
  60. // double-copying files by writing them to the right location
  61. // immediately.
  62. // It's also used on several NAS platforms (Synology, TrueNAS, etc)
  63. // but in that case DoFinalRename is also set true, which moves the
  64. // *.partial file to its final name on completion.
  65. directFileRoot string
  66. // FileOps abstracts platform-specific file operations needed for file transfers.
  67. // This is currently being used for Android to use the Storage Access Framework.
  68. fileOps FileOps
  69. nodeBackendForTest ipnext.NodeBackend // if non-nil, pretend we're this node state for tests
  70. mu sync.Mutex // Lock order: lb.mu > e.mu
  71. backendState ipn.State
  72. selfUID tailcfg.UserID
  73. capFileSharing bool
  74. fileWaiters set.HandleSet[context.CancelFunc] // of wake-up funcs
  75. mgr atomic.Pointer[manager] // mutex held to write; safe to read without lock;
  76. // outgoingFiles keeps track of Taildrop outgoing files keyed to their OutgoingFile.ID
  77. outgoingFiles map[string]*ipn.OutgoingFile
  78. }
  79. func (e *Extension) Name() string {
  80. return "taildrop"
  81. }
  82. func (e *Extension) Init(h ipnext.Host) error {
  83. e.host = h
  84. osshare.SetFileSharingEnabled(false, e.logf)
  85. h.Hooks().ProfileStateChange.Add(e.onChangeProfile)
  86. h.Hooks().OnSelfChange.Add(e.onSelfChange)
  87. h.Hooks().MutateNotifyLocked.Add(e.setNotifyFilesWaiting)
  88. h.Hooks().SetPeerStatus.Add(e.setPeerStatus)
  89. h.Hooks().BackendStateChange.Add(e.onBackendStateChange)
  90. // TODO(nickkhyl): remove this after the profileManager refactoring.
  91. // See tailscale/tailscale#15974.
  92. // This same workaround appears in feature/portlist/portlist.go.
  93. profile, prefs := h.Profiles().CurrentProfileState()
  94. e.onChangeProfile(profile, prefs, false)
  95. return nil
  96. }
  97. func (e *Extension) onBackendStateChange(st ipn.State) {
  98. e.mu.Lock()
  99. defer e.mu.Unlock()
  100. e.backendState = st
  101. }
  102. func (e *Extension) onSelfChange(self tailcfg.NodeView) {
  103. e.mu.Lock()
  104. defer e.mu.Unlock()
  105. e.selfUID = 0
  106. if self.Valid() {
  107. e.selfUID = self.User()
  108. }
  109. e.capFileSharing = self.Valid() && self.CapMap().Contains(tailcfg.CapabilityFileSharing)
  110. osshare.SetFileSharingEnabled(e.capFileSharing, e.logf)
  111. }
  112. func (e *Extension) setMgrLocked(mgr *manager) {
  113. if old := e.mgr.Swap(mgr); old != nil {
  114. old.Shutdown()
  115. }
  116. }
  117. func (e *Extension) onChangeProfile(profile ipn.LoginProfileView, _ ipn.PrefsView, sameNode bool) {
  118. e.mu.Lock()
  119. defer e.mu.Unlock()
  120. uid := profile.UserProfile().ID
  121. activeLogin := profile.UserProfile().LoginName
  122. if uid == 0 {
  123. e.setMgrLocked(nil)
  124. e.outgoingFiles = nil
  125. return
  126. }
  127. if sameNode && e.manager() != nil {
  128. return
  129. }
  130. // Use the provided [FileOps] implementation (typically for SAF access on Android),
  131. // or create an [fsFileOps] instance rooted at fileRoot.
  132. //
  133. // A non-nil [FileOps] also implies that we are in DirectFileMode.
  134. fops := e.fileOps
  135. isDirectFileMode := fops != nil
  136. if fops == nil {
  137. var fileRoot string
  138. if fileRoot, isDirectFileMode = e.fileRoot(uid, activeLogin); fileRoot == "" {
  139. e.logf("no Taildrop directory configured")
  140. e.setMgrLocked(nil)
  141. return
  142. }
  143. var err error
  144. if fops, err = newFileOps(fileRoot); err != nil {
  145. e.logf("taildrop: cannot create FileOps: %v", err)
  146. e.setMgrLocked(nil)
  147. return
  148. }
  149. }
  150. e.setMgrLocked(managerOptions{
  151. Logf: e.logf,
  152. Clock: tstime.DefaultClock{Clock: e.sb.Clock()},
  153. State: e.stateStore,
  154. DirectFileMode: isDirectFileMode,
  155. fileOps: fops,
  156. SendFileNotify: e.sendFileNotify,
  157. }.New())
  158. }
  159. // fileRoot returns where to store Taildrop files for the given user and whether
  160. // to write received files directly to this directory, without staging them in
  161. // an intermediate buffered directory for "pick-up" later.
  162. //
  163. // It is safe to call this with b.mu held but it does not require it or acquire
  164. // it itself.
  165. func (e *Extension) fileRoot(uid tailcfg.UserID, activeLogin string) (root string, isDirect bool) {
  166. if v := e.directFileRoot; v != "" {
  167. return v, true
  168. }
  169. varRoot := e.sb.TailscaleVarRoot()
  170. if varRoot == "" {
  171. e.logf("Taildrop disabled; no state directory")
  172. return "", false
  173. }
  174. if activeLogin == "" {
  175. e.logf("taildrop: no active login; can't select a target directory")
  176. return "", false
  177. }
  178. baseDir := fmt.Sprintf("%s-uid-%d",
  179. strings.ReplaceAll(activeLogin, "@", "-"),
  180. uid)
  181. return filepath.Join(varRoot, "files", baseDir), false
  182. }
  183. // hasCapFileSharing reports whether the current node has the file sharing
  184. // capability.
  185. func (e *Extension) hasCapFileSharing() bool {
  186. e.mu.Lock()
  187. defer e.mu.Unlock()
  188. return e.capFileSharing
  189. }
  190. // manager returns the active Manager, or nil.
  191. //
  192. // Methods on a nil Manager are safe to call.
  193. func (e *Extension) manager() *manager {
  194. return e.mgr.Load()
  195. }
  196. func (e *Extension) Clock() tstime.Clock {
  197. return e.sb.Clock()
  198. }
  199. func (e *Extension) Shutdown() error {
  200. e.manager().Shutdown() // no-op on nil receiver
  201. return nil
  202. }
  203. func (e *Extension) sendFileNotify() {
  204. mgr := e.manager()
  205. if mgr == nil {
  206. return
  207. }
  208. var n ipn.Notify
  209. e.mu.Lock()
  210. for _, wakeWaiter := range e.fileWaiters {
  211. wakeWaiter()
  212. }
  213. n.IncomingFiles = mgr.IncomingFiles()
  214. e.mu.Unlock()
  215. e.host.SendNotifyAsync(n)
  216. }
  217. func (e *Extension) setNotifyFilesWaiting(n *ipn.Notify) {
  218. if e.manager().HasFilesWaiting() {
  219. n.FilesWaiting = &empty.Message{}
  220. }
  221. }
  222. func (e *Extension) setPeerStatus(ps *ipnstate.PeerStatus, p tailcfg.NodeView, nb ipnext.NodeBackend) {
  223. ps.TaildropTarget = e.taildropTargetStatus(p, nb)
  224. }
  225. func (e *Extension) removeFileWaiter(handle set.Handle) {
  226. e.mu.Lock()
  227. defer e.mu.Unlock()
  228. delete(e.fileWaiters, handle)
  229. }
  230. func (e *Extension) addFileWaiter(wakeWaiter context.CancelFunc) set.Handle {
  231. e.mu.Lock()
  232. defer e.mu.Unlock()
  233. return e.fileWaiters.Add(wakeWaiter)
  234. }
  235. func (e *Extension) WaitingFiles() ([]apitype.WaitingFile, error) {
  236. return e.manager().WaitingFiles()
  237. }
  238. // AwaitWaitingFiles is like WaitingFiles but blocks while ctx is not done,
  239. // waiting for any files to be available.
  240. //
  241. // On return, exactly one of the results will be non-empty or non-nil,
  242. // respectively.
  243. func (e *Extension) AwaitWaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
  244. if ff, err := e.WaitingFiles(); err != nil || len(ff) > 0 {
  245. return ff, err
  246. }
  247. if err := ctx.Err(); err != nil {
  248. return nil, err
  249. }
  250. for {
  251. gotFile, gotFileCancel := context.WithCancel(context.Background())
  252. defer gotFileCancel()
  253. handle := e.addFileWaiter(gotFileCancel)
  254. defer e.removeFileWaiter(handle)
  255. // Now that we've registered ourselves, check again, in case
  256. // of race. Otherwise there's a small window where we could
  257. // miss a file arrival and wait forever.
  258. if ff, err := e.WaitingFiles(); err != nil || len(ff) > 0 {
  259. return ff, err
  260. }
  261. select {
  262. case <-gotFile.Done():
  263. if ff, err := e.WaitingFiles(); err != nil || len(ff) > 0 {
  264. return ff, err
  265. }
  266. case <-ctx.Done():
  267. return nil, ctx.Err()
  268. }
  269. }
  270. }
  271. func (e *Extension) DeleteFile(name string) error {
  272. return e.manager().DeleteFile(name)
  273. }
  274. func (e *Extension) OpenFile(name string) (rc io.ReadCloser, size int64, err error) {
  275. return e.manager().OpenFile(name)
  276. }
  277. func (e *Extension) nodeBackend() ipnext.NodeBackend {
  278. if e.nodeBackendForTest != nil {
  279. return e.nodeBackendForTest
  280. }
  281. return e.host.NodeBackend()
  282. }
  283. // FileTargets lists nodes that the current node can send files to.
  284. func (e *Extension) FileTargets() ([]*apitype.FileTarget, error) {
  285. var ret []*apitype.FileTarget
  286. e.mu.Lock()
  287. st := e.backendState
  288. self := e.selfUID
  289. e.mu.Unlock()
  290. if st != ipn.Running {
  291. return nil, errors.New("not connected to the tailnet")
  292. }
  293. if !e.hasCapFileSharing() {
  294. return nil, errors.New("file sharing not enabled by Tailscale admin")
  295. }
  296. nb := e.nodeBackend()
  297. peers := nb.AppendMatchingPeers(nil, func(p tailcfg.NodeView) bool {
  298. if !p.Valid() || p.Hostinfo().OS() == "tvOS" {
  299. return false
  300. }
  301. if self == p.User() {
  302. return true
  303. }
  304. if nb.PeerHasCap(p, tailcfg.PeerCapabilityFileSharingTarget) {
  305. // Explicitly noted in the netmap ACL caps as a target.
  306. return true
  307. }
  308. return false
  309. })
  310. for _, p := range peers {
  311. peerAPI := nb.PeerAPIBase(p)
  312. if peerAPI == "" {
  313. continue
  314. }
  315. ret = append(ret, &apitype.FileTarget{
  316. Node: p.AsStruct(),
  317. PeerAPIURL: peerAPI,
  318. })
  319. }
  320. slices.SortFunc(ret, func(a, b *apitype.FileTarget) int {
  321. return cmp.Compare(a.Node.Name, b.Node.Name)
  322. })
  323. return ret, nil
  324. }
  325. func (e *Extension) taildropTargetStatus(p tailcfg.NodeView, nb ipnext.NodeBackend) ipnstate.TaildropTargetStatus {
  326. e.mu.Lock()
  327. st := e.backendState
  328. selfUID := e.selfUID
  329. capFileSharing := e.capFileSharing
  330. e.mu.Unlock()
  331. if st != ipn.Running {
  332. return ipnstate.TaildropTargetIpnStateNotRunning
  333. }
  334. if !capFileSharing {
  335. return ipnstate.TaildropTargetMissingCap
  336. }
  337. if !p.Valid() {
  338. return ipnstate.TaildropTargetNoPeerInfo
  339. }
  340. if !p.Online().Get() {
  341. return ipnstate.TaildropTargetOffline
  342. }
  343. if p.Hostinfo().OS() == "tvOS" {
  344. return ipnstate.TaildropTargetUnsupportedOS
  345. }
  346. if selfUID != p.User() {
  347. // Different user must have the explicit file sharing target capability
  348. if !nb.PeerHasCap(p, tailcfg.PeerCapabilityFileSharingTarget) {
  349. return ipnstate.TaildropTargetOwnedByOtherUser
  350. }
  351. }
  352. if !nb.PeerHasPeerAPI(p) {
  353. return ipnstate.TaildropTargetNoPeerAPI
  354. }
  355. return ipnstate.TaildropTargetAvailable
  356. }
  357. // updateOutgoingFiles updates b.outgoingFiles to reflect the given updates and
  358. // sends an ipn.Notify with the full list of outgoingFiles.
  359. func (e *Extension) updateOutgoingFiles(updates map[string]*ipn.OutgoingFile) {
  360. e.mu.Lock()
  361. if e.outgoingFiles == nil {
  362. e.outgoingFiles = make(map[string]*ipn.OutgoingFile, len(updates))
  363. }
  364. maps.Copy(e.outgoingFiles, updates)
  365. outgoingFiles := make([]*ipn.OutgoingFile, 0, len(e.outgoingFiles))
  366. for _, file := range e.outgoingFiles {
  367. outgoingFiles = append(outgoingFiles, file)
  368. }
  369. e.mu.Unlock()
  370. slices.SortFunc(outgoingFiles, func(a, b *ipn.OutgoingFile) int {
  371. t := a.Started.Compare(b.Started)
  372. if t != 0 {
  373. return t
  374. }
  375. return strings.Compare(a.Name, b.Name)
  376. })
  377. e.host.SendNotifyAsync(ipn.Notify{OutgoingFiles: outgoingFiles})
  378. }