diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index 544fe9089..53a37fe01 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -908,7 +908,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ tailscale.com/sessionrecording from tailscale.com/k8s-operator/sessionrecording+ tailscale.com/syncs from tailscale.com/control/controlknobs+ tailscale.com/tailcfg from tailscale.com/client/local+ - tailscale.com/taildrop from tailscale.com/ipn/ipnlocal+ + tailscale.com/taildrop from tailscale.com/feature/taildrop tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock tailscale.com/tempfork/httprec from tailscale.com/control/controlclient @@ -965,7 +965,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ tailscale.com/util/nocasemaps from tailscale.com/types/ipproto 💣 tailscale.com/util/osdiag from tailscale.com/ipn/localapi W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag - tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal + tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal+ tailscale.com/util/osuser from tailscale.com/ipn/ipnlocal tailscale.com/util/progresstracking from tailscale.com/feature/taildrop tailscale.com/util/race from tailscale.com/net/dns/resolver diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index c5d5a7b2d..aa11fb9f3 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -359,7 +359,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled tailscale.com/syncs from tailscale.com/cmd/tailscaled+ tailscale.com/tailcfg from tailscale.com/client/local+ - tailscale.com/taildrop from tailscale.com/ipn/ipnlocal+ + tailscale.com/taildrop from tailscale.com/feature/taildrop tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock diff --git a/cmd/tailscaled/taildrop_omit.go b/cmd/tailscaled/taildrop_omit.go deleted file mode 100644 index 3b7669391..000000000 --- a/cmd/tailscaled/taildrop_omit.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build ts_omit_taildrop - -package main - -import ( - "tailscale.com/ipn/ipnlocal" - "tailscale.com/types/logger" -) - -func configureTaildrop(logf logger.Logf, lb *ipnlocal.LocalBackend) { - // Nothing. -} diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go index 4b0dc95f9..87750bc5d 100644 --- a/cmd/tailscaled/tailscaled.go +++ b/cmd/tailscaled/tailscaled.go @@ -660,7 +660,6 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID Socket: args.socketpath, UseSocketOnly: args.socketpath != paths.DefaultTailscaledSocket(), }) - configureTaildrop(logf, lb) if err := ns.Start(lb); err != nil { log.Fatalf("failed to start netstack: %v", err) } diff --git a/feature/relayserver/relayserver.go b/feature/relayserver/relayserver.go index e5c2afc17..87aba4228 100644 --- a/feature/relayserver/relayserver.go +++ b/feature/relayserver/relayserver.go @@ -133,7 +133,7 @@ func (e *extension) relayServerOrInit() (relayServer, error) { } func handlePeerAPIRelayAllocateEndpoint(h ipnlocal.PeerAPIHandler, w http.ResponseWriter, r *http.Request) { - e, ok := h.LocalBackend().FindExtensionByName(featureName).(*extension) + e, ok := ipnlocal.GetExt[*extension](h.LocalBackend()) if !ok { http.Error(w, "relay failed to initialize", http.StatusServiceUnavailable) return diff --git a/feature/taildrop/ext.go b/feature/taildrop/ext.go index b7cfdec72..b86c0f926 100644 --- a/feature/taildrop/ext.go +++ b/feature/taildrop/ext.go @@ -4,10 +4,30 @@ package taildrop import ( + "cmp" + "context" + "errors" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "sync/atomic" + + "tailscale.com/client/tailscale/apitype" + "tailscale.com/ipn" "tailscale.com/ipn/ipnext" - "tailscale.com/ipn/ipnlocal" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" "tailscale.com/taildrop" + "tailscale.com/tstime" + "tailscale.com/types/empty" "tailscale.com/types/logger" + "tailscale.com/util/osshare" + "tailscale.com/util/set" ) func init() { @@ -15,38 +35,374 @@ func init() { } func newExtension(logf logger.Logf, b ipnext.SafeBackend) (ipnext.Extension, error) { - return &extension{ - logf: logger.WithPrefix(logf, "taildrop: "), - }, nil + e := &Extension{ + sb: b, + stateStore: b.Sys().StateStore.Get(), + logf: logger.WithPrefix(logf, "taildrop: "), + } + e.setPlatformDefaultDirectFileRoot() + return e, nil } -type extension struct { - logf logger.Logf - sb ipnext.SafeBackend - mgr *taildrop.Manager +// Extension implements Taildrop. +type Extension struct { + logf logger.Logf + sb ipnext.SafeBackend + stateStore ipn.StateStore + host ipnext.Host // from Init + + // directFileRoot, if non-empty, means to write received files + // directly to this directory, without staging them in an + // intermediate buffered directory for "pick-up" later. If + // empty, the files are received in a daemon-owned location + // and the localapi is used to enumerate, download, and delete + // them. This is used on macOS where the GUI lifetime is the + // same as the Network Extension lifetime and we can thus avoid + // double-copying files by writing them to the right location + // immediately. + // It's also used on several NAS platforms (Synology, TrueNAS, etc) + // but in that case DoFinalRename is also set true, which moves the + // *.partial file to its final name on completion. + directFileRoot string + + nodeBackendForTest ipnext.NodeBackend // if non-nil, pretend we're this node state for tests + + mu sync.Mutex // Lock order: lb.mu > e.mu + backendState ipn.State + selfUID tailcfg.UserID + capFileSharing bool + fileWaiters set.HandleSet[context.CancelFunc] // of wake-up funcs + mgr atomic.Pointer[taildrop.Manager] // mutex held to write; safe to read without lock; + // outgoingFiles keeps track of Taildrop outgoing files keyed to their OutgoingFile.ID + outgoingFiles map[string]*ipn.OutgoingFile } -func (e *extension) Name() string { +func (e *Extension) Name() string { return "taildrop" } -func (e *extension) Init(h ipnext.Host) error { - // TODO(bradfitz): move init of taildrop.Manager from ipnlocal/peerapi.go to - // here - e.mgr = nil +func (e *Extension) Init(h ipnext.Host) error { + e.host = h + + osshare.SetFileSharingEnabled(false, e.logf) + + h.Hooks().ProfileStateChange.Add(e.onChangeProfile) + h.Hooks().OnSelfChange.Add(e.onSelfChange) + h.Hooks().MutateNotifyLocked.Add(e.setNotifyFilesWaiting) + h.Hooks().SetPeerStatus.Add(e.setPeerStatus) + h.Hooks().BackendStateChange.Add(e.onBackendStateChange) return nil } -func (e *extension) Shutdown() error { - lb, ok := e.sb.(*ipnlocal.LocalBackend) - if !ok { - return nil +func (e *Extension) onBackendStateChange(st ipn.State) { + e.mu.Lock() + defer e.mu.Unlock() + e.backendState = st +} + +func (e *Extension) onSelfChange(self tailcfg.NodeView) { + e.mu.Lock() + defer e.mu.Unlock() + + e.selfUID = 0 + if self.Valid() { + e.selfUID = self.User() } - if mgr, err := lb.TaildropManager(); err == nil { - mgr.Shutdown() - } else { - e.logf("taildrop: failed to shutdown taildrop manager: %v", err) + e.capFileSharing = self.Valid() && self.CapMap().Contains(tailcfg.CapabilityFileSharing) + osshare.SetFileSharingEnabled(e.capFileSharing, e.logf) +} + +func (e *Extension) setMgrLocked(mgr *taildrop.Manager) { + if old := e.mgr.Swap(mgr); old != nil { + old.Shutdown() } +} + +func (e *Extension) onChangeProfile(profile ipn.LoginProfileView, _ ipn.PrefsView, sameNode bool) { + e.mu.Lock() + defer e.mu.Unlock() + + uid := profile.UserProfile().ID + activeLogin := profile.UserProfile().LoginName + + if uid == 0 { + e.setMgrLocked(nil) + e.outgoingFiles = nil + return + } + + if sameNode && e.manager() != nil { + return + } + + // If we have a netmap, create a taildrop manager. + fileRoot, isDirectFileMode := e.fileRoot(uid, activeLogin) + if fileRoot == "" { + e.logf("no Taildrop directory configured") + } + e.setMgrLocked(taildrop.ManagerOptions{ + Logf: e.logf, + Clock: tstime.DefaultClock{Clock: e.sb.Clock()}, + State: e.stateStore, + Dir: fileRoot, + DirectFileMode: isDirectFileMode, + SendFileNotify: e.sendFileNotify, + }.New()) +} + +// fileRoot returns where to store Taildrop files for the given user and whether +// to write received files directly to this directory, without staging them in +// an intermediate buffered directory for "pick-up" later. +// +// It is safe to call this with b.mu held but it does not require it or acquire +// it itself. +func (e *Extension) fileRoot(uid tailcfg.UserID, activeLogin string) (root string, isDirect bool) { + if v := e.directFileRoot; v != "" { + return v, true + } + varRoot := e.sb.TailscaleVarRoot() + if varRoot == "" { + e.logf("Taildrop disabled; no state directory") + return "", false + } + + if activeLogin == "" { + e.logf("taildrop: no active login; can't select a target directory") + return "", false + } + + baseDir := fmt.Sprintf("%s-uid-%d", + strings.ReplaceAll(activeLogin, "@", "-"), + uid) + dir := filepath.Join(varRoot, "files", baseDir) + if err := os.MkdirAll(dir, 0700); err != nil { + e.logf("Taildrop disabled; error making directory: %v", err) + return "", false + } + return dir, false +} + +// hasCapFileSharing reports whether the current node has the file sharing +// capability. +func (e *Extension) hasCapFileSharing() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.capFileSharing +} + +// manager returns the active taildrop.Manager, or nil. +// +// Methods on a nil Manager are safe to call. +func (e *Extension) manager() *taildrop.Manager { + return e.mgr.Load() +} + +func (e *Extension) Clock() tstime.Clock { + return e.sb.Clock() +} + +func (e *Extension) Shutdown() error { + e.manager().Shutdown() // no-op on nil receiver return nil } + +func (e *Extension) sendFileNotify() { + mgr := e.manager() + if mgr == nil { + return + } + + var n ipn.Notify + + e.mu.Lock() + for _, wakeWaiter := range e.fileWaiters { + wakeWaiter() + } + n.IncomingFiles = mgr.IncomingFiles() + e.mu.Unlock() + + e.host.SendNotifyAsync(n) +} + +func (e *Extension) setNotifyFilesWaiting(n *ipn.Notify) { + if e.manager().HasFilesWaiting() { + n.FilesWaiting = &empty.Message{} + } +} + +func (e *Extension) setPeerStatus(ps *ipnstate.PeerStatus, p tailcfg.NodeView, nb ipnext.NodeBackend) { + ps.TaildropTarget = e.taildropTargetStatus(p, nb) +} + +func (e *Extension) removeFileWaiter(handle set.Handle) { + e.mu.Lock() + defer e.mu.Unlock() + delete(e.fileWaiters, handle) +} + +func (e *Extension) addFileWaiter(wakeWaiter context.CancelFunc) set.Handle { + e.mu.Lock() + defer e.mu.Unlock() + return e.fileWaiters.Add(wakeWaiter) +} + +func (e *Extension) WaitingFiles() ([]apitype.WaitingFile, error) { + return e.manager().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 (e *Extension) AwaitWaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) { + if ff, err := e.WaitingFiles(); err != nil || len(ff) > 0 { + return ff, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + for { + gotFile, gotFileCancel := context.WithCancel(context.Background()) + defer gotFileCancel() + + handle := e.addFileWaiter(gotFileCancel) + defer e.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 := e.WaitingFiles(); err != nil || len(ff) > 0 { + return ff, err + } + + select { + case <-gotFile.Done(): + if ff, err := e.WaitingFiles(); err != nil || len(ff) > 0 { + return ff, err + } + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} + +func (e *Extension) DeleteFile(name string) error { + return e.manager().DeleteFile(name) +} + +func (e *Extension) OpenFile(name string) (rc io.ReadCloser, size int64, err error) { + return e.manager().OpenFile(name) +} + +func (e *Extension) nodeBackend() ipnext.NodeBackend { + if e.nodeBackendForTest != nil { + return e.nodeBackendForTest + } + return e.host.NodeBackend() +} + +// FileTargets lists nodes that the current node can send files to. +func (e *Extension) FileTargets() ([]*apitype.FileTarget, error) { + var ret []*apitype.FileTarget + + e.mu.Lock() + st := e.backendState + self := e.selfUID + e.mu.Unlock() + + if st != ipn.Running { + return nil, errors.New("not connected to the tailnet") + } + if !e.hasCapFileSharing() { + return nil, errors.New("file sharing not enabled by Tailscale admin") + } + nb := e.nodeBackend() + peers := nb.AppendMatchingPeers(nil, func(p tailcfg.NodeView) bool { + if !p.Valid() || p.Hostinfo().OS() == "tvOS" { + return false + } + if self == p.User() { + return true + } + if nb.PeerHasCap(p, tailcfg.PeerCapabilityFileSharingTarget) { + // Explicitly noted in the netmap ACL caps as a target. + return true + } + return false + }) + for _, p := range peers { + peerAPI := nb.PeerAPIBase(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 (e *Extension) taildropTargetStatus(p tailcfg.NodeView, nb ipnext.NodeBackend) ipnstate.TaildropTargetStatus { + e.mu.Lock() + st := e.backendState + selfUID := e.selfUID + capFileSharing := e.capFileSharing + e.mu.Unlock() + + if st != ipn.Running { + return ipnstate.TaildropTargetIpnStateNotRunning + } + + if !capFileSharing { + return ipnstate.TaildropTargetMissingCap + } + if !p.Valid() { + return ipnstate.TaildropTargetNoPeerInfo + } + if !p.Online().Get() { + return ipnstate.TaildropTargetOffline + } + if p.Hostinfo().OS() == "tvOS" { + return ipnstate.TaildropTargetUnsupportedOS + } + if selfUID != p.User() { + // Different user must have the explicit file sharing target capability + if !nb.PeerHasCap(p, tailcfg.PeerCapabilityFileSharingTarget) { + return ipnstate.TaildropTargetOwnedByOtherUser + } + } + if !nb.PeerHasPeerAPI(p) { + return ipnstate.TaildropTargetNoPeerAPI + } + return ipnstate.TaildropTargetAvailable +} + +// updateOutgoingFiles updates b.outgoingFiles to reflect the given updates and +// sends an ipn.Notify with the full list of outgoingFiles. +func (e *Extension) updateOutgoingFiles(updates map[string]*ipn.OutgoingFile) { + e.mu.Lock() + if e.outgoingFiles == nil { + e.outgoingFiles = make(map[string]*ipn.OutgoingFile, len(updates)) + } + maps.Copy(e.outgoingFiles, updates) + outgoingFiles := make([]*ipn.OutgoingFile, 0, len(e.outgoingFiles)) + for _, file := range e.outgoingFiles { + outgoingFiles = append(outgoingFiles, file) + } + e.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) + }) + + e.host.SendNotifyAsync(ipn.Notify{OutgoingFiles: outgoingFiles}) +} diff --git a/feature/taildrop/localapi.go b/feature/taildrop/localapi.go index 067a51f91..02e6b0b52 100644 --- a/feature/taildrop/localapi.go +++ b/feature/taildrop/localapi.go @@ -21,6 +21,7 @@ import ( "tailscale.com/client/tailscale/apitype" "tailscale.com/ipn" + "tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/localapi" "tailscale.com/tailcfg" "tailscale.com/taildrop" @@ -80,9 +81,13 @@ func serveFilePut(h *localapi.Handler, w http.ResponseWriter, r *http.Request) { return } - lb := h.LocalBackend() + ext, ok := ipnlocal.GetExt[*Extension](h.LocalBackend()) + if !ok { + http.Error(w, "misconfigured taildrop extension", http.StatusInternalServerError) + return + } - fts, err := lb.FileTargets() + fts, err := ext.FileTargets() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -131,7 +136,7 @@ func serveFilePut(h *localapi.Handler, w http.ResponseWriter, r *http.Request) { go func() { defer t.Stop() - defer lb.UpdateOutgoingFiles(outgoingFiles) + defer ext.updateOutgoingFiles(outgoingFiles) for { select { case u, ok := <-progressUpdates: @@ -140,7 +145,7 @@ func serveFilePut(h *localapi.Handler, w http.ResponseWriter, r *http.Request) { } outgoingFiles[u.ID] = &u case <-t.C: - lb.UpdateOutgoingFiles(outgoingFiles) + ext.updateOutgoingFiles(outgoingFiles) } } }() @@ -301,7 +306,11 @@ func singleFilePut( fail() return false } - switch resp, err := client.Do(req); { + resp, err := client.Do(req) + if resp != nil { + defer resp.Body.Close() + } + switch { case err != nil: h.Logf("could not fetch remote hashes: %v", err) case resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotFound: @@ -353,7 +362,13 @@ func serveFiles(h *localapi.Handler, w http.ResponseWriter, r *http.Request) { http.Error(w, "file access denied", http.StatusForbidden) return } - lb := h.LocalBackend() + + ext, ok := ipnlocal.GetExt[*Extension](h.LocalBackend()) + if !ok { + http.Error(w, "misconfigured taildrop extension", http.StatusInternalServerError) + return + } + suffix, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/files/") if !ok { http.Error(w, "misconfigured", http.StatusInternalServerError) @@ -376,14 +391,14 @@ func serveFiles(h *localapi.Handler, w http.ResponseWriter, r *http.Request) { var cancel context.CancelFunc ctx, cancel = context.WithDeadline(ctx, deadline) defer cancel() - wfs, err = lb.AwaitWaitingFiles(ctx) + wfs, err = ext.AwaitWaitingFiles(ctx) if err != nil && ctx.Err() == nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } else { var err error - wfs, err = lb.WaitingFiles() + wfs, err = ext.WaitingFiles() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -399,14 +414,14 @@ func serveFiles(h *localapi.Handler, w http.ResponseWriter, r *http.Request) { return } if r.Method == "DELETE" { - if err := lb.DeleteFile(name); err != nil { + if err := ext.DeleteFile(name); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) return } - rc, size, err := lb.OpenFile(name) + rc, size, err := ext.OpenFile(name) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -426,7 +441,14 @@ func serveFileTargets(h *localapi.Handler, w http.ResponseWriter, r *http.Reques http.Error(w, "want GET to list targets", http.StatusBadRequest) return } - fts, err := h.LocalBackend().FileTargets() + + ext, ok := ipnlocal.GetExt[*Extension](h.LocalBackend()) + if !ok { + http.Error(w, "misconfigured taildrop extension", http.StatusInternalServerError) + return + } + + fts, err := ext.FileTargets() if err != nil { localapi.WriteErrorJSON(w, err) return diff --git a/cmd/tailscaled/taildrop.go b/feature/taildrop/paths.go similarity index 89% rename from cmd/tailscaled/taildrop.go rename to feature/taildrop/paths.go index 3eda9bebf..1129fbcfa 100644 --- a/cmd/tailscaled/taildrop.go +++ b/feature/taildrop/paths.go @@ -1,35 +1,38 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -//go:build !ts_omit_taildrop - -package main +package taildrop import ( "fmt" "os" "path/filepath" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/types/logger" "tailscale.com/version/distro" ) -func configureTaildrop(logf logger.Logf, lb *ipnlocal.LocalBackend) { +// SetDirectFileRoot sets the directory where received files are written. +// +// This must be called before Tailscale is started. +func (e *Extension) SetDirectFileRoot(root string) { + e.directFileRoot = root +} + +func (e *Extension) setPlatformDefaultDirectFileRoot() { dg := distro.Get() + switch dg { case distro.Synology, distro.TrueNAS, distro.QNAP, distro.Unraid: // See if they have a "Taildrop" share. // See https://github.com/tailscale/tailscale/issues/2179#issuecomment-982821319 path, err := findTaildropDir(dg) if err != nil { - logf("%s Taildrop support: %v", dg, err) + e.logf("%s Taildrop support: %v", dg, err) } else { - logf("%s Taildrop: using %v", dg, path) - lb.SetDirectFileRoot(path) + e.logf("%s Taildrop: using %v", dg, path) + e.directFileRoot = path } } - } func findTaildropDir(dg distro.Distro) (string, error) { diff --git a/feature/taildrop/peerapi.go b/feature/taildrop/peerapi.go index f90dca9dc..a81ce9c3a 100644 --- a/feature/taildrop/peerapi.go +++ b/feature/taildrop/peerapi.go @@ -38,26 +38,30 @@ func canPutFile(h ipnlocal.PeerAPIHandler) bool { } func handlePeerPut(h ipnlocal.PeerAPIHandler, w http.ResponseWriter, r *http.Request) { - lb := h.LocalBackend() - handlePeerPutWithBackend(h, lb, w, r) + ext, ok := ipnlocal.GetExt[*Extension](h.LocalBackend()) + if !ok { + http.Error(w, "miswired", http.StatusInternalServerError) + return + } + handlePeerPutWithBackend(h, ext, w, r) } -// localBackend is the subset of ipnlocal.Backend that taildrop +// extensionForPut is the subset of taildrop extension that taildrop // file put needs. This is pulled out for testability. -type localBackend interface { - TaildropManager() (*taildrop.Manager, error) - HasCapFileSharing() bool +type extensionForPut interface { + manager() *taildrop.Manager + hasCapFileSharing() bool Clock() tstime.Clock } -func handlePeerPutWithBackend(h ipnlocal.PeerAPIHandler, lb localBackend, w http.ResponseWriter, r *http.Request) { +func handlePeerPutWithBackend(h ipnlocal.PeerAPIHandler, ext extensionForPut, w http.ResponseWriter, r *http.Request) { if r.Method == "PUT" { metricPutCalls.Add(1) } - taildropMgr, err := lb.TaildropManager() - if err != nil { - h.Logf("taildropManager: %v", err) + taildropMgr := ext.manager() + if taildropMgr == nil { + h.Logf("taildrop: no taildrop manager") http.Error(w, "failed to get taildrop manager", http.StatusInternalServerError) return } @@ -66,7 +70,7 @@ func handlePeerPutWithBackend(h ipnlocal.PeerAPIHandler, lb localBackend, w http http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusForbidden) return } - if !lb.HasCapFileSharing() { + if !ext.hasCapFileSharing() { http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusForbidden) return } @@ -123,7 +127,7 @@ func handlePeerPutWithBackend(h ipnlocal.PeerAPIHandler, lb localBackend, w http } } case "PUT": - t0 := lb.Clock().Now() + t0 := ext.Clock().Now() id := taildrop.ClientID(h.Peer().StableID()) var offset int64 @@ -138,7 +142,7 @@ func handlePeerPutWithBackend(h ipnlocal.PeerAPIHandler, lb localBackend, w http n, err := taildropMgr.PutFile(taildrop.ClientID(fmt.Sprint(id)), baseName, r.Body, offset, r.ContentLength) switch err { case nil: - d := lb.Clock().Since(t0).Round(time.Second / 10) + d := ext.Clock().Since(t0).Round(time.Second / 10) h.Logf("got put of %s in %v from %v/%v", approxSize(n), d, h.RemoteAddr().Addr(), h.Peer().ComputedName) io.WriteString(w, "{}\n") case taildrop.ErrNoTaildrop: diff --git a/feature/taildrop/peerapi_test.go b/feature/taildrop/peerapi_test.go index 46a61f547..a647add37 100644 --- a/feature/taildrop/peerapi_test.go +++ b/feature/taildrop/peerapi_test.go @@ -50,19 +50,19 @@ func (h *peerAPIHandler) PeerCaps() tailcfg.PeerCapMap { return nil } -type fakeLocalBackend struct { +type fakeExtension struct { logf logger.Logf capFileSharing bool clock tstime.Clock taildrop *taildrop.Manager } -func (lb *fakeLocalBackend) Clock() tstime.Clock { return lb.clock } -func (lb *fakeLocalBackend) HasCapFileSharing() bool { - return lb.capFileSharing +func (lb *fakeExtension) manager() *taildrop.Manager { + return lb.taildrop } -func (lb *fakeLocalBackend) TaildropManager() (*taildrop.Manager, error) { - return lb.taildrop, nil +func (lb *fakeExtension) Clock() tstime.Clock { return lb.clock } +func (lb *fakeExtension) hasCapFileSharing() bool { + return lb.capFileSharing } type peerAPITestEnv struct { @@ -472,16 +472,17 @@ func TestHandlePeerAPI(t *testing.T) { selfNode.CapMap = tailcfg.NodeCapMap{tailcfg.CapabilityDebug: nil} } var rootDir string - var e peerAPITestEnv if !tt.omitRoot { rootDir = t.TempDir() - e.taildrop = taildrop.ManagerOptions{ - Logf: e.logBuf.Logf, - Dir: rootDir, - }.New() } - lb := &fakeLocalBackend{ + var e peerAPITestEnv + e.taildrop = taildrop.ManagerOptions{ + Logf: e.logBuf.Logf, + Dir: rootDir, + }.New() + + ext := &fakeExtension{ logf: e.logBuf.Logf, capFileSharing: tt.capSharing, clock: &tstest.Clock{}, @@ -499,7 +500,7 @@ func TestHandlePeerAPI(t *testing.T) { if req.Host == "example.com" { req.Host = "100.100.100.101:12345" } - handlePeerPutWithBackend(e.ph, lb, e.rr, req) + handlePeerPutWithBackend(e.ph, ext, e.rr, req) } for _, f := range tt.checks { f(t, &e) @@ -539,7 +540,7 @@ func TestFileDeleteRace(t *testing.T) { Addresses: []netip.Prefix{netip.MustParsePrefix("100.100.100.101/32")}, }).View(), } - fakeLB := &fakeLocalBackend{ + fakeLB := &fakeExtension{ logf: t.Logf, capFileSharing: true, clock: &tstest.Clock{}, diff --git a/feature/taildrop/target_test.go b/feature/taildrop/target_test.go new file mode 100644 index 000000000..57c96a77a --- /dev/null +++ b/feature/taildrop/target_test.go @@ -0,0 +1,73 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package taildrop + +import ( + "fmt" + "testing" + + "tailscale.com/ipn" + "tailscale.com/ipn/ipnext" + "tailscale.com/tailcfg" +) + +func TestFileTargets(t *testing.T) { + e := new(Extension) + + _, err := e.FileTargets() + if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want { + t.Errorf("before connect: got %q; want %q", got, want) + } + + e.nodeBackendForTest = testNodeBackend{peers: nil} + + _, err = e.FileTargets() + if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want { + t.Errorf("non-running netmap: got %q; want %q", got, want) + } + + e.backendState = ipn.Running + _, err = e.FileTargets() + if got, want := fmt.Sprint(err), "file sharing not enabled by Tailscale admin"; got != want { + t.Errorf("without cap: got %q; want %q", got, want) + } + + e.capFileSharing = true + got, err := e.FileTargets() + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("unexpected %d peers", len(got)) + } + + var nodeID tailcfg.NodeID = 1234 + peer := &tailcfg.Node{ + ID: nodeID, + Hostinfo: (&tailcfg.Hostinfo{OS: "tvOS"}).View(), + } + e.nodeBackendForTest = testNodeBackend{peers: []tailcfg.NodeView{peer.View()}} + + got, err = e.FileTargets() + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("unexpected %d peers", len(got)) + } +} + +type testNodeBackend struct { + ipnext.NodeBackend + peers []tailcfg.NodeView +} + +func (t testNodeBackend) AppendMatchingPeers(peers []tailcfg.NodeView, f func(tailcfg.NodeView) bool) []tailcfg.NodeView { + for _, p := range t.peers { + if f(p) { + peers = append(peers, p) + } + } + return peers +} diff --git a/ipn/ipnext/ipnext.go b/ipn/ipnext/ipnext.go index bd8d3d79c..895fadc1c 100644 --- a/ipn/ipnext/ipnext.go +++ b/ipn/ipnext/ipnext.go @@ -9,11 +9,14 @@ import ( "errors" "fmt" "iter" + "net/netip" "tailscale.com/control/controlclient" "tailscale.com/feature" "tailscale.com/ipn" "tailscale.com/ipn/ipnauth" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" "tailscale.com/tsd" "tailscale.com/tstime" "tailscale.com/types/logger" @@ -191,6 +194,10 @@ type Host interface { // SendNotifyAsync sends a notification to the IPN bus, // typically to the GUI client. SendNotifyAsync(ipn.Notify) + + // NodeBackend returns the [NodeBackend] for the currently active node + // (which is approximately the same as the current profile). + NodeBackend() NodeBackend } // SafeBackend is a subset of the [ipnlocal.LocalBackend] type's methods that @@ -323,7 +330,9 @@ type NewControlClientCallback func(controlclient.Client, ipn.LoginProfileView) ( // Each hook has its own rules about when it's called and what environment it // has access to and what it's allowed to do. type Hooks struct { - // ProfileStateChange are callbacks that are invoked when the current login profile + // BackendStateChange is called when the backend state changes. + BackendStateChange feature.Hooks[func(ipn.State)] + // or its [ipn.Prefs] change, after those changes have been made. The current login profile // may be changed either because of a profile switch, or because the profile information // was updated by [LocalBackend.SetControlClientStatus], including when the profile @@ -347,4 +356,42 @@ type Hooks struct { // NewControlClient are the functions to be called when a new control client // is created. It is called with the LocalBackend locked. NewControlClient feature.Hooks[NewControlClientCallback] + + // OnSelfChange is called (with LocalBackend.mu held) when the self node + // changes, including changing to nothing (an invalid view). + OnSelfChange feature.Hooks[func(tailcfg.NodeView)] + + // MutateNotifyLocked is called to optionally mutate the provided Notify + // before sending it to the IPN bus. It is called with LocalBackend.mu held. + MutateNotifyLocked feature.Hooks[func(*ipn.Notify)] + + // SetPeerStatus is called to mutate PeerStatus. + // Callers must only use NodeBackend to read data. + SetPeerStatus feature.Hooks[func(*ipnstate.PeerStatus, tailcfg.NodeView, NodeBackend)] +} + +// NodeBackend is an interface to query the current node and its peers. +// +// It is not a snapshot in time but is locked to a particular node. +type NodeBackend interface { + // AppendMatchingPeers appends all peers that match the predicate + // to the base slice and returns it. + AppendMatchingPeers(base []tailcfg.NodeView, pred func(tailcfg.NodeView) bool) []tailcfg.NodeView + + // PeerCaps returns the capabilities that src has to this node. + PeerCaps(src netip.Addr) tailcfg.PeerCapMap + + // PeerHasCap reports whether the peer has the specified peer capability. + PeerHasCap(peer tailcfg.NodeView, cap tailcfg.PeerCapability) bool + + // PeerAPIBase returns the "http://ip:port" URL base to reach peer's + // PeerAPI, or the empty string if the peer is invalid or doesn't support + // PeerAPI. + PeerAPIBase(tailcfg.NodeView) string + + // PeerHasPeerAPI whether the provided peer supports PeerAPI. + // + // It effectively just reports whether PeerAPIBase(node) is non-empty, but + // potentially more efficiently. + PeerHasPeerAPI(tailcfg.NodeView) bool } diff --git a/ipn/ipnlocal/drive.go b/ipn/ipnlocal/drive.go index f13c9de48..a06ea5e8c 100644 --- a/ipn/ipnlocal/drive.go +++ b/ipn/ipnlocal/drive.go @@ -336,11 +336,8 @@ func (b *LocalBackend) driveRemotesFromPeers(nm *netmap.NetworkMap) []*drive.Rem } // Check that the peer is allowed to share with us. - addresses := peer.Addresses() - for _, p := range addresses.All() { - if cn.PeerHasCap(p.Addr(), tailcfg.PeerCapabilityTaildriveSharer) { - return true - } + if cn.PeerHasCap(peer, tailcfg.PeerCapabilityTaildriveSharer) { + return true } return false diff --git a/ipn/ipnlocal/extension_host.go b/ipn/ipnlocal/extension_host.go index bf0e6091c..faf9d2be9 100644 --- a/ipn/ipnlocal/extension_host.go +++ b/ipn/ipnlocal/extension_host.go @@ -87,6 +87,8 @@ type ExtensionHost struct { shuttingDown atomic.Bool + extByType sync.Map // reflect.Type -> ipnext.Extension + // mu protects the following fields. // It must not be held when calling [LocalBackend] methods // or when invoking callbacks registered by extensions. @@ -117,6 +119,9 @@ type Backend interface { SwitchToBestProfile(reason string) SendNotify(ipn.Notify) + + NodeBackend() ipnext.NodeBackend + ipnext.SafeBackend } @@ -183,6 +188,13 @@ func newExtensionHost(logf logger.Logf, b Backend, overrideExts ...*ipnext.Defin return host, nil } +func (h *ExtensionHost) NodeBackend() ipnext.NodeBackend { + if h == nil { + return nil + } + return h.b.NodeBackend() +} + // Init initializes the host and the extensions it manages. func (h *ExtensionHost) Init() { if h != nil { @@ -229,6 +241,7 @@ func (h *ExtensionHost) init() { h.mu.Lock() h.activeExtensions = append(h.activeExtensions, ext) h.extensionsByName[ext.Name()] = ext + h.extByType.Store(reflect.TypeOf(ext), ext) h.mu.Unlock() } @@ -276,6 +289,29 @@ func (h *ExtensionHost) FindExtensionByName(name string) any { // extensionIfaceType is the runtime type of the [ipnext.Extension] interface. var extensionIfaceType = reflect.TypeFor[ipnext.Extension]() +// GetExt returns the extension of type T registered with lb. +// If lb is nil or the extension is not found, it returns zero, false. +func GetExt[T ipnext.Extension](lb *LocalBackend) (_ T, ok bool) { + var zero T + if lb == nil { + return zero, false + } + if ext, ok := lb.extHost.extensionOfType(reflect.TypeFor[T]()); ok { + return ext.(T), true + } + return zero, false +} + +func (h *ExtensionHost) extensionOfType(t reflect.Type) (_ ipnext.Extension, ok bool) { + if h == nil { + return nil, false + } + if v, ok := h.extByType.Load(t); ok { + return v.(ipnext.Extension), true + } + return nil, false +} + // FindMatchingExtension implements [ipnext.ExtensionServices] // and is also used by the [LocalBackend]. func (h *ExtensionHost) FindMatchingExtension(target any) bool { diff --git a/ipn/ipnlocal/extension_host_test.go b/ipn/ipnlocal/extension_host_test.go index aa4a27d45..8816e659f 100644 --- a/ipn/ipnlocal/extension_host_test.go +++ b/ipn/ipnlocal/extension_host_test.go @@ -1335,8 +1335,9 @@ func (b *testBackend) Clock() tstime.Clock { return tstime.StdClock{} } func (b *testBackend) Sys() *tsd.System { return b.lazySys.Get(tsd.NewSystem) } -func (b *testBackend) SendNotify(ipn.Notify) { panic("not implemented") } -func (b *testBackend) TailscaleVarRoot() string { panic("not implemented") } +func (b *testBackend) SendNotify(ipn.Notify) { panic("not implemented") } +func (b *testBackend) NodeBackend() ipnext.NodeBackend { panic("not implemented") } +func (b *testBackend) TailscaleVarRoot() string { panic("not implemented") } func (b *testBackend) SwitchToBestProfile(reason string) { b.mu.Lock() diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index b2998d11c..a7935c6cd 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -26,7 +26,6 @@ import ( "net/url" "os" "os/exec" - "path/filepath" "reflect" "runtime" "slices" @@ -58,6 +57,7 @@ import ( "tailscale.com/ipn" "tailscale.com/ipn/conffile" "tailscale.com/ipn/ipnauth" + "tailscale.com/ipn/ipnext" "tailscale.com/ipn/ipnstate" "tailscale.com/ipn/policy" "tailscale.com/log/sockstatlog" @@ -277,37 +277,23 @@ type LocalBackend struct { capFileSharing bool // whether netMap contains the file sharing capability capTailnetLock bool // whether netMap contains the tailnet lock capability // hostinfo is mutated in-place while mu is held. - hostinfo *tailcfg.Hostinfo // TODO(nickkhyl): move to nodeContext - nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil; TODO(nickkhyl): move to nodeContext - activeLogin string // last logged LoginName from netMap; TODO(nickkhyl): move to nodeContext (or remove? it's in [ipn.LoginProfile]). - engineStatus ipn.EngineStatus - endpoints []tailcfg.Endpoint - blocked bool - keyExpired bool // TODO(nickkhyl): move to nodeContext - authURL string // non-empty if not Running; TODO(nickkhyl): move to nodeContext - authURLTime time.Time // when the authURL was received from the control server; TODO(nickkhyl): move to nodeContext - authActor ipnauth.Actor // an actor who called [LocalBackend.StartLoginInteractive] last, or nil; TODO(nickkhyl): move to nodeContext - egg bool - prevIfState *netmon.State - peerAPIServer *peerAPIServer // or nil - peerAPIListeners []*peerAPIListener - loginFlags controlclient.LoginFlags - fileWaiters set.HandleSet[context.CancelFunc] // of wake-up funcs - notifyWatchers map[string]*watchSession // by session ID - lastStatusTime time.Time // status.AsOf value of the last processed status update - // directFileRoot, if non-empty, means to write received files - // directly to this directory, without staging them in an - // intermediate buffered directory for "pick-up" later. If - // empty, the files are received in a daemon-owned location - // and the localapi is used to enumerate, download, and delete - // them. This is used on macOS where the GUI lifetime is the - // same as the Network Extension lifetime and we can thus avoid - // double-copying files by writing them to the right location - // immediately. - // It's also used on several NAS platforms (Synology, TrueNAS, etc) - // but in that case DoFinalRename is also set true, which moves the - // *.partial file to its final name on completion. - directFileRoot string + hostinfo *tailcfg.Hostinfo // TODO(nickkhyl): move to nodeContext + nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil; TODO(nickkhyl): move to nodeContext + activeLogin string // last logged LoginName from netMap; TODO(nickkhyl): move to nodeContext (or remove? it's in [ipn.LoginProfile]). + engineStatus ipn.EngineStatus + endpoints []tailcfg.Endpoint + blocked bool + keyExpired bool // TODO(nickkhyl): move to nodeContext + authURL string // non-empty if not Running; TODO(nickkhyl): move to nodeContext + authURLTime time.Time // when the authURL was received from the control server; TODO(nickkhyl): move to nodeContext + authActor ipnauth.Actor // an actor who called [LocalBackend.StartLoginInteractive] last, or nil; TODO(nickkhyl): move to nodeContext + egg bool + prevIfState *netmon.State + peerAPIServer *peerAPIServer // or nil + peerAPIListeners []*peerAPIListener + loginFlags controlclient.LoginFlags + notifyWatchers map[string]*watchSession // by session ID + lastStatusTime time.Time // status.AsOf value of the last processed status update componentLogUntil map[string]componentLogState // c2nUpdateStatus is the status of c2n-triggered client update. c2nUpdateStatus updateStatus @@ -371,9 +357,6 @@ type LocalBackend struct { // http://go/corp/25168 lastKnownHardwareAddrs syncs.AtomicValue[[]string] - // outgoingFiles keeps track of Taildrop outgoing files keyed to their OutgoingFile.ID - outgoingFiles map[string]*ipn.OutgoingFile - // lastSuggestedExitNode stores the last suggested exit node suggestion to // avoid unnecessary churn between multiple equally-good options. lastSuggestedExitNode tailcfg.StableNodeID @@ -594,6 +577,11 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo func (b *LocalBackend) Clock() tstime.Clock { return b.clock } func (b *LocalBackend) Sys() *tsd.System { return b.sys } +// NodeBackend returns the current node's NodeBackend interface. +func (b *LocalBackend) NodeBackend() ipnext.NodeBackend { + return b.currentNode() +} + func (b *LocalBackend) currentNode() *nodeBackend { if v := b.currentNodeAtomic.Load(); v != nil || !testenv.InTest() { return v @@ -772,17 +760,6 @@ func (b *LocalBackend) Dialer() *tsdial.Dialer { return b.dialer } -// SetDirectFileRoot sets the directory to download files to directly, -// without buffering them through an intermediate daemon-owned -// tailcfg.UserID-specific directory. -// -// This must be called before the LocalBackend starts being used. -func (b *LocalBackend) SetDirectFileRoot(dir string) { - b.mu.Lock() - defer b.mu.Unlock() - b.directFileRoot = dir -} - // ReloadConfig reloads the backend's config from disk. // // It returns (false, nil) if not running in declarative mode, (true, nil) on @@ -844,6 +821,16 @@ func (b *LocalBackend) setStaticEndpointsFromConfigLocked(conf *conffile.Config) } } +func (b *LocalBackend) setStateLocked(state ipn.State) { + if b.state == state { + return + } + b.state = state + for _, f := range b.extHost.Hooks().BackendStateChange { + f(state) + } +} + // setConfigLockedOnEntry uses the provided config to update the backend's prefs // and other state. func (b *LocalBackend) setConfigLockedOnEntry(conf *conffile.Config, unlock unlockOnce) error { @@ -1309,8 +1296,8 @@ func (b *LocalBackend) populatePeerStatusLocked(sb *ipnstate.StatusBuilder) { Location: p.Hostinfo().Location().AsStruct(), Capabilities: p.Capabilities().AsSlice(), } - if f := hookSetPeerStatusTaildropTargetLocked; f != nil { - f(b, ps, p) + for _, f := range b.extHost.Hooks().SetPeerStatus { + f(ps, p, cn) } if cm := p.CapMap(); cm.Len() > 0 { ps.CapMap = make(tailcfg.NodeCapMap, cm.Len()) @@ -2357,7 +2344,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error { hostinfo.Services = b.hostinfo.Services // keep any previous services } b.hostinfo = hostinfo - b.state = ipn.NoState + b.setStateLocked(ipn.NoState) cn := b.currentNode() if opts.UpdatePrefs != nil { @@ -3316,17 +3303,6 @@ func (b *LocalBackend) sendTo(n ipn.Notify, recipient notificationTarget) { b.sendToLocked(n, recipient) } -var ( - // hookSetNotifyFilesWaitingLocked, if non-nil, is called in sendToLocked to - // populate ipn.Notify.FilesWaiting when taildrop is linked in to the binary - // and enabled on a LocalBackend. - hookSetNotifyFilesWaitingLocked func(*LocalBackend, *ipn.Notify) - - // hookSetPeerStatusTaildropTargetLocked, if non-nil, is called to populate PeerStatus - // if taildrop is linked in to the binary and enabled on the LocalBackend. - hookSetPeerStatusTaildropTargetLocked func(*LocalBackend, *ipnstate.PeerStatus, tailcfg.NodeView) -) - // sendToLocked is like [LocalBackend.sendTo], but assumes b.mu is already held. func (b *LocalBackend) sendToLocked(n ipn.Notify, recipient notificationTarget) { if n.Prefs != nil { @@ -3336,8 +3312,8 @@ func (b *LocalBackend) sendToLocked(n ipn.Notify, recipient notificationTarget) n.Version = version.Long() } - if f := hookSetNotifyFilesWaitingLocked; f != nil { - f(b, &n) + for _, f := range b.extHost.Hooks().MutateNotifyLocked { + f(&n) } for _, sess := range b.notifyWatchers { @@ -5266,26 +5242,6 @@ func (b *LocalBackend) TailscaleVarRoot() string { return "" } -func (b *LocalBackend) fileRootLocked(uid tailcfg.UserID) string { - if v := b.directFileRoot; v != "" { - return v - } - varRoot := b.TailscaleVarRoot() - if varRoot == "" { - b.logf("Taildrop disabled; no state directory") - return "" - } - baseDir := fmt.Sprintf("%s-uid-%d", - strings.ReplaceAll(b.activeLogin, "@", "-"), - uid) - dir := filepath.Join(varRoot, "files", baseDir) - if err := os.MkdirAll(dir, 0700); err != nil { - b.logf("Taildrop disabled; error making directory: %v", err) - return "" - } - return dir -} - // closePeerAPIListenersLocked closes any existing PeerAPI listeners // and clears out the PeerAPI server state. // @@ -5353,8 +5309,7 @@ func (b *LocalBackend) initPeerAPIListener() { } ps := &peerAPIServer{ - b: b, - taildrop: b.newTaildropManager(b.fileRootLocked(selfNode.User())), + b: b, } if dm, ok := b.sys.DNSManager.GetOK(); ok { ps.resolver = dm.Resolver() @@ -5643,7 +5598,7 @@ func (b *LocalBackend) enterState(newState ipn.State) { func (b *LocalBackend) enterStateLockedOnEntry(newState ipn.State, unlock unlockOnce) { cn := b.currentNode() oldState := b.state - b.state = newState + b.setStateLocked(newState) prefs := b.pm.CurrentPrefs() // Some temporary (2024-05-05) debugging code to help us catch @@ -6158,6 +6113,8 @@ func (nb *nodeBackend) SetNetMap(nm *netmap.NetworkMap) { // received nm. If nm is nil, it resets all configuration as though // Tailscale is turned off. func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) { + oldSelf := b.currentNode().NetMap().SelfNodeOrZero() + b.dialer.SetNetMap(nm) if ns, ok := b.sys.Netstack.GetOK(); ok { ns.UpdateNetstackIPs(nm) @@ -6205,6 +6162,13 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) { b.setTCPPortsInterceptedFromNetmapAndPrefsLocked(b.pm.CurrentPrefs()) b.ipVIPServiceMap = nm.GetIPVIPServiceMap() + + if !oldSelf.Equal(nm.SelfNodeOrZero()) { + for _, f := range b.extHost.Hooks().OnSelfChange { + f(nm.SelfNode) + } + } + if nm == nil { // If there is no netmap, the client is going into a "turned off" // state so reset the metrics. @@ -6667,12 +6631,21 @@ func (b *LocalBackend) TestOnlyPublicKeys() (machineKey key.MachinePublic, nodeK return mk, nk } -// PeerHasCap reports whether the peer with the given Tailscale IP addresses -// contains the given capability string, with any value(s). -func (nb *nodeBackend) PeerHasCap(addr netip.Addr, wantCap tailcfg.PeerCapability) bool { +// PeerHasCap reports whether the peer contains the given capability string, +// with any value(s). +func (nb *nodeBackend) PeerHasCap(peer tailcfg.NodeView, wantCap tailcfg.PeerCapability) bool { + if !peer.Valid() { + return false + } + nb.mu.Lock() defer nb.mu.Unlock() - return nb.peerHasCapLocked(addr, wantCap) + for _, ap := range peer.Addresses().All() { + if nb.peerHasCapLocked(ap.Addr(), wantCap) { + return true + } + } + return false } func (nb *nodeBackend) peerHasCapLocked(addr netip.Addr, wantCap tailcfg.PeerCapability) bool { diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index 2b4c07749..675623f33 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/ipn/ipnlocal/peerapi.go @@ -60,8 +60,6 @@ type peerDNSQueryHandler interface { type peerAPIServer struct { b *LocalBackend resolver peerDNSQueryHandler - - taildrop *taildrop_Manager } func (s *peerAPIServer) listen(ip netip.Addr, ifState *netmon.State) (ln net.Listener, err error) { diff --git a/ipn/ipnlocal/taildrop.go b/ipn/ipnlocal/taildrop.go deleted file mode 100644 index d8113d219..000000000 --- a/ipn/ipnlocal/taildrop.go +++ /dev/null @@ -1,280 +0,0 @@ -// 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() // for b.{state,capFileSharing} - defer b.mu.Unlock() - cn := b.currentNode() - nm := cn.NetMap() - self := cn.SelfUserID() - 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") - } - peers := cn.AppendMatchingPeers(nil, func(p tailcfg.NodeView) bool { - if !p.Valid() || p.Hostinfo().OS() == "tvOS" { - return false - } - if self == p.User() { - return true - } - if p.Addresses().Len() != 0 && cn.PeerHasCap(p.Addresses().At(0).Addr(), tailcfg.PeerCapabilityFileSharingTarget) { - // Explicitly noted in the netmap ACL caps as a target. - return true - } - return false - }) - for _, p := range peers { - peerAPI := cn.PeerAPIBase(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 - } - cn := b.currentNode() - nm := cn.NetMap() - if nm == nil { - return ipnstate.TaildropTargetNoNetmapAvailable - } - if !b.capFileSharing { - return ipnstate.TaildropTargetMissingCap - } - - if !p.Online().Get() { - return ipnstate.TaildropTargetOffline - } - - if !p.Valid() { - return ipnstate.TaildropTargetNoPeerInfo - } - if nm.User() != p.User() { - // Different user must have the explicit file sharing target capability - if p.Addresses().Len() == 0 || !cn.PeerHasCap(p.Addresses().At(0).Addr(), tailcfg.PeerCapabilityFileSharingTarget) { - // Explicitly noted in the netmap ACL caps as a target. - return ipnstate.TaildropTargetOwnedByOtherUser - } - } - - if p.Hostinfo().OS() == "tvOS" { - return ipnstate.TaildropTargetUnsupportedOS - } - if !cn.PeerHasPeerAPI(p) { - return ipnstate.TaildropTargetNoPeerAPI - } - return ipnstate.TaildropTargetAvailable -} - -// 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}) -} diff --git a/ipn/ipnlocal/taildrop_omit.go b/ipn/ipnlocal/taildrop_omit.go deleted file mode 100644 index 07d2d5cc0..000000000 --- a/ipn/ipnlocal/taildrop_omit.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build ts_omit_taildrop - -package ipnlocal - -type taildrop_Manager = struct{} - -func (b *LocalBackend) newTaildropManager(fileRoot string) *taildrop_Manager { - return nil -} diff --git a/ipn/ipnlocal/taildrop_test.go b/ipn/ipnlocal/taildrop_test.go deleted file mode 100644 index a5166e8a3..000000000 --- a/ipn/ipnlocal/taildrop_test.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !ts_omit_taildrop - -package ipnlocal - -import ( - "fmt" - "testing" - - "tailscale.com/ipn" - "tailscale.com/tailcfg" - "tailscale.com/tstest/deptest" - "tailscale.com/types/netmap" -) - -func TestFileTargets(t *testing.T) { - b := new(LocalBackend) - _, err := b.FileTargets() - if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want { - t.Errorf("before connect: got %q; want %q", got, want) - } - - b.currentNode().SetNetMap(new(netmap.NetworkMap)) - _, err = b.FileTargets() - if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want { - t.Errorf("non-running netmap: got %q; want %q", got, want) - } - - b.state = ipn.Running - _, err = b.FileTargets() - if got, want := fmt.Sprint(err), "file sharing not enabled by Tailscale admin"; got != want { - t.Errorf("without cap: got %q; want %q", got, want) - } - - b.capFileSharing = true - got, err := b.FileTargets() - if err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Fatalf("unexpected %d peers", len(got)) - } - - nm := &netmap.NetworkMap{ - Peers: []tailcfg.NodeView{ - (&tailcfg.Node{ - ID: 1234, - Hostinfo: (&tailcfg.Hostinfo{OS: "tvOS"}).View(), - }).View(), - }, - } - b.currentNode().SetNetMap(nm) - got, err = b.FileTargets() - if err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Fatalf("unexpected %d peers", len(got)) - } - // (other cases handled by TestPeerAPIBase above) -} - -func TestOmitTaildropDeps(t *testing.T) { - deptest.DepChecker{ - Tags: "ts_omit_taildrop", - GOOS: "linux", - GOARCH: "amd64", - BadDeps: map[string]string{ - "tailscale.com/taildrop": "should be omitted", - "tailscale.com/feature/taildrop": "should be omitted", - }, - }.Check(t) -} diff --git a/types/netmap/netmap.go b/types/netmap/netmap.go index b1eecaa8f..c6250c49c 100644 --- a/types/netmap/netmap.go +++ b/types/netmap/netmap.go @@ -148,6 +148,14 @@ func (nm *NetworkMap) GetIPVIPServiceMap() IPServiceMappings { return res } +// SelfNodeOrZero returns the self node, or a zero value if nm is nil. +func (nm *NetworkMap) SelfNodeOrZero() tailcfg.NodeView { + if nm == nil { + return tailcfg.NodeView{} + } + return nm.SelfNode +} + // AnyPeersAdvertiseRoutes reports whether any peer is advertising non-exit node routes. func (nm *NetworkMap) AnyPeersAdvertiseRoutes() bool { for _, p := range nm.Peers {