diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 738c17072..dbcfcc40b 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -298,6 +298,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/net/netcheck+ tailscale.com/tailcfg from tailscale.com/client/tailscale/apitype+ + tailscale.com/taildrop from tailscale.com/ipn/ipnlocal 💣 tailscale.com/tempfork/device from tailscale.com/net/tstun/table LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock @@ -470,7 +471,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de flag from net/http/httptest+ fmt from compress/flate+ hash from crypto+ - hash/adler32 from tailscale.com/ipn/ipnlocal+ + hash/adler32 from compress/zlib+ hash/crc32 from compress/gzip+ hash/fnv from tailscale.com/wgengine/magicsock+ hash/maphash from go4.org/mem diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index fb1546279..031e399d4 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -62,6 +62,7 @@ import ( "tailscale.com/portlist" "tailscale.com/syncs" "tailscale.com/tailcfg" + "tailscale.com/taildrop" "tailscale.com/tka" "tailscale.com/tsd" "tailscale.com/tstime" @@ -2176,7 +2177,7 @@ func (b *LocalBackend) send(n ipn.Notify) { b.mu.Lock() notifyFunc := b.notify apiSrv := b.peerAPIServer - if apiSrv.hasFilesWaiting() { + if mayDeref(apiSrv).taildrop.HasFilesWaiting() { n.FilesWaiting = &empty.Message{} } @@ -3546,10 +3547,14 @@ func (b *LocalBackend) initPeerAPIListener() { } ps := &peerAPIServer{ - b: b, - rootDir: fileRoot, - directFileMode: b.directFileRoot != "", - directFileDoFinalRename: b.directFileDoFinalRename, + b: b, + taildrop: &taildrop.Handler{ + Logf: b.logf, + Clock: b.clock, + RootDir: fileRoot, + DirectFileMode: b.directFileRoot != "", + DirectFileDoFinalRename: b.directFileDoFinalRename, + }, } if dm, ok := b.sys.DNSManager.GetOK(); ok { ps.resolver = dm.Resolver() @@ -4433,7 +4438,7 @@ func (b *LocalBackend) WaitingFiles() ([]apitype.WaitingFile, error) { b.mu.Lock() apiSrv := b.peerAPIServer b.mu.Unlock() - return apiSrv.WaitingFiles() + return mayDeref(apiSrv).taildrop.WaitingFiles() } // AwaitWaitingFiles is like WaitingFiles but blocks while ctx is not done, @@ -4475,14 +4480,14 @@ func (b *LocalBackend) DeleteFile(name string) error { b.mu.Lock() apiSrv := b.peerAPIServer b.mu.Unlock() - return apiSrv.DeleteFile(name) + return mayDeref(apiSrv).taildrop.DeleteFile(name) } func (b *LocalBackend) OpenFile(name string) (rc io.ReadCloser, size int64, err error) { b.mu.Lock() apiSrv := b.peerAPIServer b.mu.Unlock() - return apiSrv.OpenFile(name) + return mayDeref(apiSrv).taildrop.OpenFile(name) } // hasCapFileSharing reports whether the current node has the file @@ -5297,3 +5302,11 @@ func (b *LocalBackend) DebugBreakTCPConns() error { func (b *LocalBackend) DebugBreakDERPConns() error { return b.magicConn().DebugBreakDERPConns() } + +// mayDeref dereferences p if non-nil, otherwise it returns the zero value. +func mayDeref[T any](p *T) (v T) { + if p == nil { + return v + } + return *p +} diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index fdab1004b..d0e15681c 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/ipn/ipnlocal/peerapi.go @@ -9,47 +9,38 @@ import ( "encoding/json" "errors" "fmt" - "hash/adler32" "hash/crc32" "html" "io" - "io/fs" "net" "net/http" "net/netip" "net/url" "os" - "path" - "path/filepath" "runtime" "slices" "sort" "strconv" "strings" "sync" - "sync/atomic" "time" - "unicode" - "unicode/utf8" "github.com/kortschak/wol" "golang.org/x/net/dns/dnsmessage" "golang.org/x/net/http/httpguts" - "tailscale.com/client/tailscale/apitype" "tailscale.com/envknob" "tailscale.com/health" "tailscale.com/hostinfo" "tailscale.com/ipn" - "tailscale.com/logtail/backoff" "tailscale.com/net/dns/resolver" "tailscale.com/net/interfaces" "tailscale.com/net/netaddr" "tailscale.com/net/netutil" "tailscale.com/net/sockstats" "tailscale.com/tailcfg" + "tailscale.com/taildrop" "tailscale.com/types/views" "tailscale.com/util/clientmetric" - "tailscale.com/util/multierr" "tailscale.com/version/distro" "tailscale.com/wgengine/filter" ) @@ -61,393 +52,16 @@ var initListenConfig func(*net.ListenConfig, netip.Addr, *interfaces.State, stri var addH2C func(*http.Server) type peerAPIServer struct { - b *LocalBackend - rootDir string // empty means file receiving unavailable - knownEmpty atomic.Bool - resolver *resolver.Resolver - - // directFileMode is whether we're writing files directly to a - // download directory (as *.partial files), rather than making - // the frontend retrieve it over localapi HTTP and write it - // somewhere itself. This is used on the GUI macOS versions - // and on Synology. - // In directFileMode, the peerapi doesn't do the final rename - // from "foo.jpg.partial" to "foo.jpg" unless - // directFileDoFinalRename is set. - directFileMode bool - - // directFileDoFinalRename is whether in directFileMode we - // additionally move the *.direct file to its final name after - // it's received. - directFileDoFinalRename bool -} - -const ( - // partialSuffix is the suffix appended to files while they're - // still in the process of being transferred. - partialSuffix = ".partial" - - // deletedSuffix is the suffix for a deleted marker file - // that's placed next to a file (without the suffix) that we - // tried to delete, but Windows wouldn't let us. These are - // only written on Windows (and in tests), but they're not - // permitted to be uploaded directly on any platform, like - // partial files. - deletedSuffix = ".deleted" -) - -func validFilenameRune(r rune) bool { - switch r { - case '/': - return false - case '\\', ':', '*', '"', '<', '>', '|': - // Invalid stuff on Windows, but we reject them everywhere - // for now. - // TODO(bradfitz): figure out a better plan. We initially just - // wrote things to disk URL path-escaped, but that's gross - // when debugging, and just moves the problem to callers. - // So now we put the UTF-8 filenames on disk directly as - // sent. - return false - } - return unicode.IsPrint(r) -} + b *LocalBackend + resolver *resolver.Resolver -func (s *peerAPIServer) diskPath(baseName string) (fullPath string, ok bool) { - if !utf8.ValidString(baseName) { - return "", false - } - if strings.TrimSpace(baseName) != baseName { - return "", false - } - if len(baseName) > 255 { - return "", false - } - // TODO: validate unicode normalization form too? Varies by platform. - clean := path.Clean(baseName) - if clean != baseName || - clean == "." || clean == ".." || - strings.HasSuffix(clean, deletedSuffix) || - strings.HasSuffix(clean, partialSuffix) { - return "", false - } - for _, r := range baseName { - if !validFilenameRune(r) { - return "", false - } - } - if !filepath.IsLocal(baseName) { - return "", false - } - return filepath.Join(s.rootDir, baseName), true -} - -// hasFilesWaiting reports whether any files are buffered in the -// tailscaled daemon storage. -func (s *peerAPIServer) hasFilesWaiting() bool { - if s == nil || s.rootDir == "" || s.directFileMode { - return false - } - if s.knownEmpty.Load() { - // Optimization: this is usually empty, so avoid opening - // the directory and checking. We can't cache the actual - // has-files-or-not values as the macOS/iOS client might - // in the future use+delete the files directly. So only - // keep this negative cache. - return false - } - f, err := os.Open(s.rootDir) - if err != nil { - return false - } - defer f.Close() - for { - des, err := f.ReadDir(10) - for _, de := range des { - name := de.Name() - if strings.HasSuffix(name, partialSuffix) { - continue - } - if name, ok := strings.CutSuffix(name, deletedSuffix); ok { // for Windows + tests - // After we're done looping over files, then try - // to delete this file. Don't do it proactively, - // as the OS may return "foo.jpg.deleted" before "foo.jpg" - // and we don't want to delete the ".deleted" file before - // enumerating to the "foo.jpg" file. - defer tryDeleteAgain(filepath.Join(s.rootDir, name)) - continue - } - if de.Type().IsRegular() { - _, err := os.Stat(filepath.Join(s.rootDir, name+deletedSuffix)) - if os.IsNotExist(err) { - return true - } - if err == nil { - tryDeleteAgain(filepath.Join(s.rootDir, name)) - continue - } - } - } - if err == io.EOF { - s.knownEmpty.Store(true) - } - if err != nil { - break - } - } - return false -} - -// WaitingFiles returns the list of files that have been sent by a -// peer that are waiting in the buffered "pick up" directory owned by -// the Tailscale daemon. -// -// As a side effect, it also does any lazy deletion of files as -// required by Windows. -func (s *peerAPIServer) WaitingFiles() (ret []apitype.WaitingFile, err error) { - if s == nil { - return nil, errNilPeerAPIServer - } - if s.rootDir == "" { - return nil, errNoTaildrop - } - if s.directFileMode { - return nil, nil - } - f, err := os.Open(s.rootDir) - if err != nil { - return nil, err - } - defer f.Close() - var deleted map[string]bool // "foo.jpg" => true (if "foo.jpg.deleted" exists) - for { - des, err := f.ReadDir(10) - for _, de := range des { - name := de.Name() - if strings.HasSuffix(name, partialSuffix) { - continue - } - if name, ok := strings.CutSuffix(name, deletedSuffix); ok { // for Windows + tests - if deleted == nil { - deleted = map[string]bool{} - } - deleted[name] = true - continue - } - if de.Type().IsRegular() { - fi, err := de.Info() - if err != nil { - continue - } - ret = append(ret, apitype.WaitingFile{ - Name: filepath.Base(name), - Size: fi.Size(), - }) - } - } - if err == io.EOF { - break - } - if err != nil { - return nil, err - } - } - if len(deleted) > 0 { - // Filter out any return values "foo.jpg" where a - // "foo.jpg.deleted" marker file exists on disk. - all := ret - ret = ret[:0] - for _, wf := range all { - if !deleted[wf.Name] { - ret = append(ret, wf) - } - } - // And do some opportunistic deleting while we're here. - // Maybe Windows is done virus scanning the file we tried - // to delete a long time ago and will let us delete it now. - for name := range deleted { - tryDeleteAgain(filepath.Join(s.rootDir, name)) - } - } - sort.Slice(ret, func(i, j int) bool { return ret[i].Name < ret[j].Name }) - return ret, nil + taildrop *taildrop.Handler } var ( errNilPeerAPIServer = errors.New("peerapi unavailable; not listening") - errNoTaildrop = errors.New("Taildrop disabled; no storage directory") ) -// tryDeleteAgain tries to delete path (and path+deletedSuffix) after -// it failed earlier. This happens on Windows when various anti-virus -// tools hook into filesystem operations and have the file open still -// while we're trying to delete it. In that case we instead mark it as -// deleted (writing a "foo.jpg.deleted" marker file), but then we -// later try to clean them up. -// -// fullPath is the full path to the file without the deleted suffix. -func tryDeleteAgain(fullPath string) { - if err := os.Remove(fullPath); err == nil || os.IsNotExist(err) { - os.Remove(fullPath + deletedSuffix) - } -} - -func (s *peerAPIServer) DeleteFile(baseName string) error { - if s == nil { - return errNilPeerAPIServer - } - if s.rootDir == "" { - return errNoTaildrop - } - if s.directFileMode { - return errors.New("deletes not allowed in direct mode") - } - path, ok := s.diskPath(baseName) - if !ok { - return errors.New("bad filename") - } - var bo *backoff.Backoff - logf := s.b.logf - t0 := s.b.clock.Now() - for { - err := os.Remove(path) - if err != nil && !os.IsNotExist(err) { - err = redactErr(err) - // Put a retry loop around deletes on Windows. Windows - // file descriptor closes are effectively asynchronous, - // as a bunch of hooks run on/after close, and we can't - // necessarily delete the file for a while after close, - // as we need to wait for everybody to be done with - // it. (on Windows, unlike Unix, a file can't be deleted - // if it's open anywhere) - // So try a few times but ultimately just leave a - // "foo.jpg.deleted" marker file to note that it's - // deleted and we clean it up later. - if runtime.GOOS == "windows" { - if bo == nil { - bo = backoff.NewBackoff("delete-retry", logf, 1*time.Second) - } - if s.b.clock.Since(t0) < 5*time.Second { - bo.BackOff(context.Background(), err) - continue - } - if err := touchFile(path + deletedSuffix); err != nil { - logf("peerapi: failed to leave deleted marker: %v", err) - } - } - logf("peerapi: failed to DeleteFile: %v", err) - return err - } - return nil - } -} - -// redacted is a fake path name we use in errors, to avoid -// accidentally logging actual filenames anywhere. -const redacted = "redacted" - -type redactedErr struct { - msg string - inner error -} - -func (re *redactedErr) Error() string { - return re.msg -} - -func (re *redactedErr) Unwrap() error { - return re.inner -} - -func redactString(s string) string { - hash := adler32.Checksum([]byte(s)) - - var buf [len(redacted) + len(".12345678")]byte - b := append(buf[:0], []byte(redacted)...) - b = append(b, '.') - b = strconv.AppendUint(b, uint64(hash), 16) - return string(b) -} - -func redactErr(root error) error { - // redactStrings is a list of sensitive strings that were redacted. - // It is not sufficient to just snub out sensitive fields in Go errors - // since some wrapper errors like fmt.Errorf pre-cache the error string, - // which would unfortunately remain unaffected. - var redactStrings []string - - // Redact sensitive fields in known Go error types. - var unknownErrors int - multierr.Range(root, func(err error) bool { - switch err := err.(type) { - case *os.PathError: - redactStrings = append(redactStrings, err.Path) - err.Path = redactString(err.Path) - case *os.LinkError: - redactStrings = append(redactStrings, err.New, err.Old) - err.New = redactString(err.New) - err.Old = redactString(err.Old) - default: - unknownErrors++ - } - return true - }) - - // If there are no redacted strings or no unknown error types, - // then we can return the possibly modified root error verbatim. - // Otherwise, we must replace redacted strings from any wrappers. - if len(redactStrings) == 0 || unknownErrors == 0 { - return root - } - - // Stringify and replace any paths that we found above, then return - // the error wrapped in a type that uses the newly-redacted string - // while also allowing Unwrap()-ing to the inner error type(s). - s := root.Error() - for _, toRedact := range redactStrings { - s = strings.ReplaceAll(s, toRedact, redactString(toRedact)) - } - return &redactedErr{msg: s, inner: root} -} - -func touchFile(path string) error { - f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0666) - if err != nil { - return redactErr(err) - } - return f.Close() -} - -func (s *peerAPIServer) OpenFile(baseName string) (rc io.ReadCloser, size int64, err error) { - if s == nil { - return nil, 0, errNilPeerAPIServer - } - if s.rootDir == "" { - return nil, 0, errNoTaildrop - } - if s.directFileMode { - return nil, 0, errors.New("opens not allowed in direct mode") - } - path, ok := s.diskPath(baseName) - if !ok { - return nil, 0, errors.New("bad filename") - } - if fi, err := os.Stat(path + deletedSuffix); err == nil && fi.Mode().IsRegular() { - tryDeleteAgain(path) - return nil, 0, &fs.PathError{Op: "open", Path: redacted, Err: fs.ErrNotExist} - } - f, err := os.Open(path) - if err != nil { - return nil, 0, redactErr(err) - } - fi, err := f.Stat() - if err != nil { - f.Close() - return nil, 0, redactErr(err) - } - return f, fi.Size(), nil -} - func (s *peerAPIServer) listen(ip netip.Addr, ifState *interfaces.State) (ln net.Listener, err error) { // Android for whatever reason often has problems creating the peerapi listener. // But since we started intercepting it with netstack, it's not even important that @@ -1088,11 +702,11 @@ func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) { http.Error(w, "expected method PUT", http.StatusMethodNotAllowed) return } - if h.ps.rootDir == "" { - http.Error(w, errNoTaildrop.Error(), http.StatusInternalServerError) + if mayDeref(h.ps.taildrop).RootDir == "" { + http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusInternalServerError) return } - if distro.Get() == distro.Unraid && !h.ps.directFileMode { + if distro.Get() == distro.Unraid && !h.ps.taildrop.DirectFileMode { http.Error(w, "Taildrop folder not configured or accessible", http.StatusInternalServerError) return } @@ -1115,7 +729,7 @@ func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad path encoding", 400) return } - dstFile, ok := h.ps.diskPath(baseName) + dstFile, ok := h.ps.taildrop.DiskPath(baseName) if !ok { http.Error(w, "bad filename", 400) return @@ -1129,10 +743,10 @@ func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) { return } - partialFile := dstFile + partialSuffix + partialFile := dstFile + taildrop.PartialSuffix f, err := os.Create(partialFile) if err != nil { - h.logf("put Create error: %v", redactErr(err)) + h.logf("put Create error: %v", taildrop.RedactErr(err)) http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -1152,14 +766,14 @@ func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) { w: f, ph: h, } - if h.ps.directFileMode { + if h.ps.taildrop.DirectFileMode { inFile.partialPath = partialFile } h.ps.b.registerIncomingFile(inFile, true) defer h.ps.b.registerIncomingFile(inFile, false) n, err := io.Copy(inFile, r.Body) if err != nil { - err = redactErr(err) + err = taildrop.RedactErr(err) f.Close() h.logf("put Copy error: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) @@ -1167,18 +781,18 @@ func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) { } finalSize = n } - if err := redactErr(f.Close()); err != nil { + if err := taildrop.RedactErr(f.Close()); err != nil { h.logf("put Close error: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } - if h.ps.directFileMode && !h.ps.directFileDoFinalRename { + if h.ps.taildrop.DirectFileMode && !h.ps.taildrop.DirectFileDoFinalRename { if inFile != nil { // non-zero length; TODO: notify even for zero length inFile.markAndNotifyDone() } } else { if err := os.Rename(partialFile, dstFile); err != nil { - err = redactErr(err) + err = taildrop.RedactErr(err) h.logf("put final rename: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -1192,7 +806,7 @@ func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) { // TODO: some real response success = true io.WriteString(w, "{}\n") - h.ps.knownEmpty.Store(false) + h.ps.taildrop.KnownEmpty.Store(false) h.ps.b.sendFileNotify() } diff --git a/ipn/ipnlocal/peerapi_test.go b/ipn/ipnlocal/peerapi_test.go index 2dddc9ad6..776897c50 100644 --- a/ipn/ipnlocal/peerapi_test.go +++ b/ipn/ipnlocal/peerapi_test.go @@ -23,6 +23,7 @@ import ( "tailscale.com/ipn" "tailscale.com/ipn/store/mem" "tailscale.com/tailcfg" + "tailscale.com/taildrop" "tailscale.com/tstest" "tailscale.com/types/logger" "tailscale.com/types/netmap" @@ -67,7 +68,7 @@ func bodyNotContains(sub string) check { func fileHasSize(name string, size int) check { return func(t *testing.T, e *peerAPITestEnv) { - root := e.ph.ps.rootDir + root := e.ph.ps.taildrop.RootDir if root == "" { t.Errorf("no rootdir; can't check whether %q has size %v", name, size) return @@ -83,7 +84,7 @@ func fileHasSize(name string, size int) check { func fileHasContents(name string, want string) check { return func(t *testing.T, e *peerAPITestEnv) { - root := e.ph.ps.rootDir + root := e.ph.ps.taildrop.RootDir if root == "" { t.Errorf("no rootdir; can't check contents of %q", name) return @@ -492,7 +493,10 @@ func TestHandlePeerAPI(t *testing.T) { var rootDir string if !tt.omitRoot { rootDir = t.TempDir() - e.ph.ps.rootDir = rootDir + if e.ph.ps.taildrop == nil { + e.ph.ps.taildrop = &taildrop.Handler{} + } + e.ph.ps.taildrop.RootDir = rootDir } for _, req := range tt.reqs { e.rr = httptest.NewRecorder() @@ -531,7 +535,11 @@ func TestFileDeleteRace(t *testing.T) { capFileSharing: true, clock: &tstest.Clock{}, }, - rootDir: dir, + taildrop: &taildrop.Handler{ + Logf: t.Logf, + Clock: &tstest.Clock{}, + RootDir: dir, + }, } ph := &peerAPIHandler{ isSelf: true, @@ -550,7 +558,7 @@ func TestFileDeleteRace(t *testing.T) { if res := rr.Result(); res.StatusCode != 200 { t.Fatal(res.Status) } - wfs, err := ps.WaitingFiles() + wfs, err := ps.taildrop.WaitingFiles() if err != nil { t.Fatal(err) } @@ -558,10 +566,10 @@ func TestFileDeleteRace(t *testing.T) { t.Fatalf("waiting files = %d; want 1", len(wfs)) } - if err := ps.DeleteFile("foo.txt"); err != nil { + if err := ps.taildrop.DeleteFile("foo.txt"); err != nil { t.Fatal(err) } - wfs, err = ps.WaitingFiles() + wfs, err = ps.taildrop.WaitingFiles() if err != nil { t.Fatal(err) } @@ -579,19 +587,21 @@ func TestDeletedMarkers(t *testing.T) { logf: t.Logf, capFileSharing: true, }, - rootDir: dir, + taildrop: &taildrop.Handler{ + RootDir: dir, + }, } nothingWaiting := func() { t.Helper() - ps.knownEmpty.Store(false) - if ps.hasFilesWaiting() { + ps.taildrop.KnownEmpty.Store(false) + if ps.taildrop.HasFilesWaiting() { t.Fatal("unexpected files waiting") } } touch := func(base string) { t.Helper() - if err := touchFile(filepath.Join(dir, base)); err != nil { + if err := taildrop.TouchFile(filepath.Join(dir, base)); err != nil { t.Fatal(err) } } @@ -620,7 +630,7 @@ func TestDeletedMarkers(t *testing.T) { touch("foo.jpg.deleted") touch("foo.jpg") - wf, err := ps.WaitingFiles() + wf, err := ps.taildrop.WaitingFiles() if err != nil { t.Fatal(err) } @@ -631,7 +641,7 @@ func TestDeletedMarkers(t *testing.T) { touch("foo.jpg.deleted") touch("foo.jpg") - if rc, _, err := ps.OpenFile("foo.jpg"); err == nil { + if rc, _, err := ps.taildrop.OpenFile("foo.jpg"); err == nil { rc.Close() t.Fatal("unexpected foo.jpg open") } @@ -640,14 +650,14 @@ func TestDeletedMarkers(t *testing.T) { // And verify basics still work in non-deleted cases. touch("foo.jpg") touch("bar.jpg.deleted") - if wf, err := ps.WaitingFiles(); err != nil { + if wf, err := ps.taildrop.WaitingFiles(); err != nil { t.Error(err) } else if len(wf) != 1 { t.Errorf("WaitingFiles = %d; want 1", len(wf)) } else if wf[0].Name != "foo.jpg" { t.Errorf("unexpected waiting file %+v", wf[0]) } - if rc, _, err := ps.OpenFile("foo.jpg"); err != nil { + if rc, _, err := ps.taildrop.OpenFile("foo.jpg"); err != nil { t.Fatal(err) } else { rc.Close() @@ -756,7 +766,7 @@ func TestRedactErr(t *testing.T) { } t.Run("Root", func(t *testing.T) { - got := redactErr(tc.err()).Error() + got := taildrop.RedactErr(tc.err()).Error() if got != tc.want { t.Errorf("err = %q; want %q", got, tc.want) } @@ -765,7 +775,7 @@ func TestRedactErr(t *testing.T) { wrapped := fmt.Errorf("wrapped error: %w", tc.err()) want := "wrapped error: " + tc.want - got := redactErr(wrapped).Error() + got := taildrop.RedactErr(wrapped).Error() if got != want { t.Errorf("err = %q; want %q", got, want) } diff --git a/taildrop/receiver.go b/taildrop/receiver.go new file mode 100644 index 000000000..def924297 --- /dev/null +++ b/taildrop/receiver.go @@ -0,0 +1,253 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package taildrop + +import ( + "context" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "tailscale.com/client/tailscale/apitype" + "tailscale.com/logtail/backoff" +) + +// HasFilesWaiting reports whether any files are buffered in the +// tailscaled daemon storage. +func (s *Handler) HasFilesWaiting() bool { + if s == nil || s.RootDir == "" || s.DirectFileMode { + return false + } + if s.KnownEmpty.Load() { + // Optimization: this is usually empty, so avoid opening + // the directory and checking. We can't cache the actual + // has-files-or-not values as the macOS/iOS client might + // in the future use+delete the files directly. So only + // keep this negative cache. + return false + } + f, err := os.Open(s.RootDir) + if err != nil { + return false + } + defer f.Close() + for { + des, err := f.ReadDir(10) + for _, de := range des { + name := de.Name() + if strings.HasSuffix(name, PartialSuffix) { + continue + } + if name, ok := strings.CutSuffix(name, deletedSuffix); ok { // for Windows + tests + // After we're done looping over files, then try + // to delete this file. Don't do it proactively, + // as the OS may return "foo.jpg.deleted" before "foo.jpg" + // and we don't want to delete the ".deleted" file before + // enumerating to the "foo.jpg" file. + defer tryDeleteAgain(filepath.Join(s.RootDir, name)) + continue + } + if de.Type().IsRegular() { + _, err := os.Stat(filepath.Join(s.RootDir, name+deletedSuffix)) + if os.IsNotExist(err) { + return true + } + if err == nil { + tryDeleteAgain(filepath.Join(s.RootDir, name)) + continue + } + } + } + if err == io.EOF { + s.KnownEmpty.Store(true) + } + if err != nil { + break + } + } + return false +} + +// WaitingFiles returns the list of files that have been sent by a +// peer that are waiting in the buffered "pick up" directory owned by +// the Tailscale daemon. +// +// As a side effect, it also does any lazy deletion of files as +// required by Windows. +func (s *Handler) WaitingFiles() (ret []apitype.WaitingFile, err error) { + if s == nil { + return nil, errNilHandler + } + if s.RootDir == "" { + return nil, ErrNoTaildrop + } + if s.DirectFileMode { + return nil, nil + } + f, err := os.Open(s.RootDir) + if err != nil { + return nil, err + } + defer f.Close() + var deleted map[string]bool // "foo.jpg" => true (if "foo.jpg.deleted" exists) + for { + des, err := f.ReadDir(10) + for _, de := range des { + name := de.Name() + if strings.HasSuffix(name, PartialSuffix) { + continue + } + if name, ok := strings.CutSuffix(name, deletedSuffix); ok { // for Windows + tests + if deleted == nil { + deleted = map[string]bool{} + } + deleted[name] = true + continue + } + if de.Type().IsRegular() { + fi, err := de.Info() + if err != nil { + continue + } + ret = append(ret, apitype.WaitingFile{ + Name: filepath.Base(name), + Size: fi.Size(), + }) + } + } + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + } + if len(deleted) > 0 { + // Filter out any return values "foo.jpg" where a + // "foo.jpg.deleted" marker file exists on disk. + all := ret + ret = ret[:0] + for _, wf := range all { + if !deleted[wf.Name] { + ret = append(ret, wf) + } + } + // And do some opportunistic deleting while we're here. + // Maybe Windows is done virus scanning the file we tried + // to delete a long time ago and will let us delete it now. + for name := range deleted { + tryDeleteAgain(filepath.Join(s.RootDir, name)) + } + } + sort.Slice(ret, func(i, j int) bool { return ret[i].Name < ret[j].Name }) + return ret, nil +} + +// tryDeleteAgain tries to delete path (and path+deletedSuffix) after +// it failed earlier. This happens on Windows when various anti-virus +// tools hook into filesystem operations and have the file open still +// while we're trying to delete it. In that case we instead mark it as +// deleted (writing a "foo.jpg.deleted" marker file), but then we +// later try to clean them up. +// +// fullPath is the full path to the file without the deleted suffix. +func tryDeleteAgain(fullPath string) { + if err := os.Remove(fullPath); err == nil || os.IsNotExist(err) { + os.Remove(fullPath + deletedSuffix) + } +} + +func (s *Handler) DeleteFile(baseName string) error { + if s == nil { + return errNilHandler + } + if s.RootDir == "" { + return ErrNoTaildrop + } + if s.DirectFileMode { + return errors.New("deletes not allowed in direct mode") + } + path, ok := s.DiskPath(baseName) + if !ok { + return errors.New("bad filename") + } + var bo *backoff.Backoff + logf := s.Logf + t0 := s.Clock.Now() + for { + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + err = RedactErr(err) + // Put a retry loop around deletes on Windows. Windows + // file descriptor closes are effectively asynchronous, + // as a bunch of hooks run on/after close, and we can't + // necessarily delete the file for a while after close, + // as we need to wait for everybody to be done with + // it. (on Windows, unlike Unix, a file can't be deleted + // if it's open anywhere) + // So try a few times but ultimately just leave a + // "foo.jpg.deleted" marker file to note that it's + // deleted and we clean it up later. + if runtime.GOOS == "windows" { + if bo == nil { + bo = backoff.NewBackoff("delete-retry", logf, 1*time.Second) + } + if s.Clock.Since(t0) < 5*time.Second { + bo.BackOff(context.Background(), err) + continue + } + if err := TouchFile(path + deletedSuffix); err != nil { + logf("peerapi: failed to leave deleted marker: %v", err) + } + } + logf("peerapi: failed to DeleteFile: %v", err) + return err + } + return nil + } +} + +func TouchFile(path string) error { + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0666) + if err != nil { + return RedactErr(err) + } + return f.Close() +} + +func (s *Handler) OpenFile(baseName string) (rc io.ReadCloser, size int64, err error) { + if s == nil { + return nil, 0, errNilHandler + } + if s.RootDir == "" { + return nil, 0, ErrNoTaildrop + } + if s.DirectFileMode { + return nil, 0, errors.New("opens not allowed in direct mode") + } + path, ok := s.DiskPath(baseName) + if !ok { + return nil, 0, errors.New("bad filename") + } + if fi, err := os.Stat(path + deletedSuffix); err == nil && fi.Mode().IsRegular() { + tryDeleteAgain(path) + return nil, 0, &fs.PathError{Op: "open", Path: redacted, Err: fs.ErrNotExist} + } + f, err := os.Open(path) + if err != nil { + return nil, 0, RedactErr(err) + } + fi, err := f.Stat() + if err != nil { + f.Close() + return nil, 0, RedactErr(err) + } + return f, fi.Size(), nil +} diff --git a/taildrop/taildrop.go b/taildrop/taildrop.go new file mode 100644 index 000000000..1c9caf389 --- /dev/null +++ b/taildrop/taildrop.go @@ -0,0 +1,178 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package taildrop + +import ( + "errors" + "hash/adler32" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "unicode" + "unicode/utf8" + + "tailscale.com/tstime" + "tailscale.com/types/logger" + "tailscale.com/util/multierr" +) + +type Handler struct { + Logf logger.Logf + Clock tstime.Clock + + RootDir string // empty means file receiving unavailable + + // DirectFileMode is whether we're writing files directly to a + // download directory (as *.partial files), rather than making + // the frontend retrieve it over localapi HTTP and write it + // somewhere itself. This is used on the GUI macOS versions + // and on Synology. + // In DirectFileMode, the peerapi doesn't do the final rename + // from "foo.jpg.partial" to "foo.jpg" unless + // directFileDoFinalRename is set. + DirectFileMode bool + + // DirectFileDoFinalRename is whether in directFileMode we + // additionally move the *.direct file to its final name after + // it's received. + DirectFileDoFinalRename bool + + KnownEmpty atomic.Bool +} + +var ( + errNilHandler = errors.New("handler unavailable; not listening") + ErrNoTaildrop = errors.New("Taildrop disabled; no storage directory") +) + +const ( + // PartialSuffix is the suffix appended to files while they're + // still in the process of being transferred. + PartialSuffix = ".partial" + + // deletedSuffix is the suffix for a deleted marker file + // that's placed next to a file (without the suffix) that we + // tried to delete, but Windows wouldn't let us. These are + // only written on Windows (and in tests), but they're not + // permitted to be uploaded directly on any platform, like + // partial files. + deletedSuffix = ".deleted" +) + +// redacted is a fake path name we use in errors, to avoid +// accidentally logging actual filenames anywhere. +const redacted = "redacted" + +func validFilenameRune(r rune) bool { + switch r { + case '/': + return false + case '\\', ':', '*', '"', '<', '>', '|': + // Invalid stuff on Windows, but we reject them everywhere + // for now. + // TODO(bradfitz): figure out a better plan. We initially just + // wrote things to disk URL path-escaped, but that's gross + // when debugging, and just moves the problem to callers. + // So now we put the UTF-8 filenames on disk directly as + // sent. + return false + } + return unicode.IsPrint(r) +} + +func (s *Handler) DiskPath(baseName string) (fullPath string, ok bool) { + if !utf8.ValidString(baseName) { + return "", false + } + if strings.TrimSpace(baseName) != baseName { + return "", false + } + if len(baseName) > 255 { + return "", false + } + // TODO: validate unicode normalization form too? Varies by platform. + clean := path.Clean(baseName) + if clean != baseName || + clean == "." || clean == ".." || + strings.HasSuffix(clean, deletedSuffix) || + strings.HasSuffix(clean, PartialSuffix) { + return "", false + } + for _, r := range baseName { + if !validFilenameRune(r) { + return "", false + } + } + if !filepath.IsLocal(baseName) { + return "", false + } + return filepath.Join(s.RootDir, baseName), true +} + +type redactedErr struct { + msg string + inner error +} + +func (re *redactedErr) Error() string { + return re.msg +} + +func (re *redactedErr) Unwrap() error { + return re.inner +} + +func redactString(s string) string { + hash := adler32.Checksum([]byte(s)) + + var buf [len(redacted) + len(".12345678")]byte + b := append(buf[:0], []byte(redacted)...) + b = append(b, '.') + b = strconv.AppendUint(b, uint64(hash), 16) + return string(b) +} + +func RedactErr(root error) error { + // redactStrings is a list of sensitive strings that were redacted. + // It is not sufficient to just snub out sensitive fields in Go errors + // since some wrapper errors like fmt.Errorf pre-cache the error string, + // which would unfortunately remain unaffected. + var redactStrings []string + + // Redact sensitive fields in known Go error types. + var unknownErrors int + multierr.Range(root, func(err error) bool { + switch err := err.(type) { + case *os.PathError: + redactStrings = append(redactStrings, err.Path) + err.Path = redactString(err.Path) + case *os.LinkError: + redactStrings = append(redactStrings, err.New, err.Old) + err.New = redactString(err.New) + err.Old = redactString(err.Old) + default: + unknownErrors++ + } + return true + }) + + // If there are no redacted strings or no unknown error types, + // then we can return the possibly modified root error verbatim. + // Otherwise, we must replace redacted strings from any wrappers. + if len(redactStrings) == 0 || unknownErrors == 0 { + return root + } + + // Stringify and replace any paths that we found above, then return + // the error wrapped in a type that uses the newly-redacted string + // while also allowing Unwrap()-ing to the inner error type(s). + s := root.Error() + for _, toRedact := range redactStrings { + s = strings.ReplaceAll(s, toRedact, redactString(toRedact)) + } + return &redactedErr{msg: s, inner: root} +}