mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 04:55:31 +00:00
taildrop: initial commit of taildrop functionality refactoring (#9676)
Over time all taildrop functionality will be contained in the taildrop package. This will include end to end unit tests. This is simply the first smallest piece to move over. There is no functionality change in this commit. Updates tailscale/corp#14772 Signed-off-by: Rhea Ghosh <rhea@tailscale.com> Co-authored-by: Joseph Tsai <joetsai@tailscale.com>
This commit is contained in:
parent
3befc0ef02
commit
dc1c7cbe3e
@ -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
|
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled
|
||||||
tailscale.com/syncs from tailscale.com/net/netcheck+
|
tailscale.com/syncs from tailscale.com/net/netcheck+
|
||||||
tailscale.com/tailcfg from tailscale.com/client/tailscale/apitype+
|
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
|
💣 tailscale.com/tempfork/device from tailscale.com/net/tstun/table
|
||||||
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
|
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
|
||||||
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
|
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+
|
flag from net/http/httptest+
|
||||||
fmt from compress/flate+
|
fmt from compress/flate+
|
||||||
hash from crypto+
|
hash from crypto+
|
||||||
hash/adler32 from tailscale.com/ipn/ipnlocal+
|
hash/adler32 from compress/zlib+
|
||||||
hash/crc32 from compress/gzip+
|
hash/crc32 from compress/gzip+
|
||||||
hash/fnv from tailscale.com/wgengine/magicsock+
|
hash/fnv from tailscale.com/wgengine/magicsock+
|
||||||
hash/maphash from go4.org/mem
|
hash/maphash from go4.org/mem
|
||||||
|
@ -62,6 +62,7 @@
|
|||||||
"tailscale.com/portlist"
|
"tailscale.com/portlist"
|
||||||
"tailscale.com/syncs"
|
"tailscale.com/syncs"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/taildrop"
|
||||||
"tailscale.com/tka"
|
"tailscale.com/tka"
|
||||||
"tailscale.com/tsd"
|
"tailscale.com/tsd"
|
||||||
"tailscale.com/tstime"
|
"tailscale.com/tstime"
|
||||||
@ -2176,7 +2177,7 @@ func (b *LocalBackend) send(n ipn.Notify) {
|
|||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
notifyFunc := b.notify
|
notifyFunc := b.notify
|
||||||
apiSrv := b.peerAPIServer
|
apiSrv := b.peerAPIServer
|
||||||
if apiSrv.hasFilesWaiting() {
|
if mayDeref(apiSrv).taildrop.HasFilesWaiting() {
|
||||||
n.FilesWaiting = &empty.Message{}
|
n.FilesWaiting = &empty.Message{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3546,10 +3547,14 @@ func (b *LocalBackend) initPeerAPIListener() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ps := &peerAPIServer{
|
ps := &peerAPIServer{
|
||||||
b: b,
|
b: b,
|
||||||
rootDir: fileRoot,
|
taildrop: &taildrop.Handler{
|
||||||
directFileMode: b.directFileRoot != "",
|
Logf: b.logf,
|
||||||
directFileDoFinalRename: b.directFileDoFinalRename,
|
Clock: b.clock,
|
||||||
|
RootDir: fileRoot,
|
||||||
|
DirectFileMode: b.directFileRoot != "",
|
||||||
|
DirectFileDoFinalRename: b.directFileDoFinalRename,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if dm, ok := b.sys.DNSManager.GetOK(); ok {
|
if dm, ok := b.sys.DNSManager.GetOK(); ok {
|
||||||
ps.resolver = dm.Resolver()
|
ps.resolver = dm.Resolver()
|
||||||
@ -4433,7 +4438,7 @@ func (b *LocalBackend) WaitingFiles() ([]apitype.WaitingFile, error) {
|
|||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
apiSrv := b.peerAPIServer
|
apiSrv := b.peerAPIServer
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
return apiSrv.WaitingFiles()
|
return mayDeref(apiSrv).taildrop.WaitingFiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AwaitWaitingFiles is like WaitingFiles but blocks while ctx is not done,
|
// 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()
|
b.mu.Lock()
|
||||||
apiSrv := b.peerAPIServer
|
apiSrv := b.peerAPIServer
|
||||||
b.mu.Unlock()
|
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) {
|
func (b *LocalBackend) OpenFile(name string) (rc io.ReadCloser, size int64, err error) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
apiSrv := b.peerAPIServer
|
apiSrv := b.peerAPIServer
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
return apiSrv.OpenFile(name)
|
return mayDeref(apiSrv).taildrop.OpenFile(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasCapFileSharing reports whether the current node has the file
|
// hasCapFileSharing reports whether the current node has the file
|
||||||
@ -5297,3 +5302,11 @@ func (b *LocalBackend) DebugBreakTCPConns() error {
|
|||||||
func (b *LocalBackend) DebugBreakDERPConns() error {
|
func (b *LocalBackend) DebugBreakDERPConns() error {
|
||||||
return b.magicConn().DebugBreakDERPConns()
|
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
|
||||||
|
}
|
||||||
|
@ -9,47 +9,38 @@
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/adler32"
|
|
||||||
"hash/crc32"
|
"hash/crc32"
|
||||||
"html"
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/kortschak/wol"
|
"github.com/kortschak/wol"
|
||||||
"golang.org/x/net/dns/dnsmessage"
|
"golang.org/x/net/dns/dnsmessage"
|
||||||
"golang.org/x/net/http/httpguts"
|
"golang.org/x/net/http/httpguts"
|
||||||
"tailscale.com/client/tailscale/apitype"
|
|
||||||
"tailscale.com/envknob"
|
"tailscale.com/envknob"
|
||||||
"tailscale.com/health"
|
"tailscale.com/health"
|
||||||
"tailscale.com/hostinfo"
|
"tailscale.com/hostinfo"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/logtail/backoff"
|
|
||||||
"tailscale.com/net/dns/resolver"
|
"tailscale.com/net/dns/resolver"
|
||||||
"tailscale.com/net/interfaces"
|
"tailscale.com/net/interfaces"
|
||||||
"tailscale.com/net/netaddr"
|
"tailscale.com/net/netaddr"
|
||||||
"tailscale.com/net/netutil"
|
"tailscale.com/net/netutil"
|
||||||
"tailscale.com/net/sockstats"
|
"tailscale.com/net/sockstats"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/taildrop"
|
||||||
"tailscale.com/types/views"
|
"tailscale.com/types/views"
|
||||||
"tailscale.com/util/clientmetric"
|
"tailscale.com/util/clientmetric"
|
||||||
"tailscale.com/util/multierr"
|
|
||||||
"tailscale.com/version/distro"
|
"tailscale.com/version/distro"
|
||||||
"tailscale.com/wgengine/filter"
|
"tailscale.com/wgengine/filter"
|
||||||
)
|
)
|
||||||
@ -61,393 +52,16 @@
|
|||||||
var addH2C func(*http.Server)
|
var addH2C func(*http.Server)
|
||||||
|
|
||||||
type peerAPIServer struct {
|
type peerAPIServer struct {
|
||||||
b *LocalBackend
|
b *LocalBackend
|
||||||
rootDir string // empty means file receiving unavailable
|
resolver *resolver.Resolver
|
||||||
knownEmpty atomic.Bool
|
|
||||||
resolver *resolver.Resolver
|
|
||||||
|
|
||||||
// directFileMode is whether we're writing files directly to a
|
taildrop *taildrop.Handler
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errNilPeerAPIServer = errors.New("peerapi unavailable; not listening")
|
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) {
|
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.
|
// 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
|
// 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)
|
http.Error(w, "expected method PUT", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if h.ps.rootDir == "" {
|
if mayDeref(h.ps.taildrop).RootDir == "" {
|
||||||
http.Error(w, errNoTaildrop.Error(), http.StatusInternalServerError)
|
http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusInternalServerError)
|
||||||
return
|
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)
|
http.Error(w, "Taildrop folder not configured or accessible", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1115,7 +729,7 @@ func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad path encoding", 400)
|
http.Error(w, "bad path encoding", 400)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dstFile, ok := h.ps.diskPath(baseName)
|
dstFile, ok := h.ps.taildrop.DiskPath(baseName)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "bad filename", 400)
|
http.Error(w, "bad filename", 400)
|
||||||
return
|
return
|
||||||
@ -1129,10 +743,10 @@ func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
partialFile := dstFile + partialSuffix
|
partialFile := dstFile + taildrop.PartialSuffix
|
||||||
f, err := os.Create(partialFile)
|
f, err := os.Create(partialFile)
|
||||||
if err != nil {
|
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)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1152,14 +766,14 @@ func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
w: f,
|
w: f,
|
||||||
ph: h,
|
ph: h,
|
||||||
}
|
}
|
||||||
if h.ps.directFileMode {
|
if h.ps.taildrop.DirectFileMode {
|
||||||
inFile.partialPath = partialFile
|
inFile.partialPath = partialFile
|
||||||
}
|
}
|
||||||
h.ps.b.registerIncomingFile(inFile, true)
|
h.ps.b.registerIncomingFile(inFile, true)
|
||||||
defer h.ps.b.registerIncomingFile(inFile, false)
|
defer h.ps.b.registerIncomingFile(inFile, false)
|
||||||
n, err := io.Copy(inFile, r.Body)
|
n, err := io.Copy(inFile, r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = redactErr(err)
|
err = taildrop.RedactErr(err)
|
||||||
f.Close()
|
f.Close()
|
||||||
h.logf("put Copy error: %v", err)
|
h.logf("put Copy error: %v", err)
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
@ -1167,18 +781,18 @@ func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
finalSize = n
|
finalSize = n
|
||||||
}
|
}
|
||||||
if err := redactErr(f.Close()); err != nil {
|
if err := taildrop.RedactErr(f.Close()); err != nil {
|
||||||
h.logf("put Close error: %v", err)
|
h.logf("put Close error: %v", err)
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
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
|
if inFile != nil { // non-zero length; TODO: notify even for zero length
|
||||||
inFile.markAndNotifyDone()
|
inFile.markAndNotifyDone()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := os.Rename(partialFile, dstFile); err != nil {
|
if err := os.Rename(partialFile, dstFile); err != nil {
|
||||||
err = redactErr(err)
|
err = taildrop.RedactErr(err)
|
||||||
h.logf("put final rename: %v", err)
|
h.logf("put final rename: %v", err)
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@ -1192,7 +806,7 @@ func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
// TODO: some real response
|
// TODO: some real response
|
||||||
success = true
|
success = true
|
||||||
io.WriteString(w, "{}\n")
|
io.WriteString(w, "{}\n")
|
||||||
h.ps.knownEmpty.Store(false)
|
h.ps.taildrop.KnownEmpty.Store(false)
|
||||||
h.ps.b.sendFileNotify()
|
h.ps.b.sendFileNotify()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/store/mem"
|
"tailscale.com/ipn/store/mem"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/taildrop"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
"tailscale.com/types/logger"
|
"tailscale.com/types/logger"
|
||||||
"tailscale.com/types/netmap"
|
"tailscale.com/types/netmap"
|
||||||
@ -67,7 +68,7 @@ func bodyNotContains(sub string) check {
|
|||||||
|
|
||||||
func fileHasSize(name string, size int) check {
|
func fileHasSize(name string, size int) check {
|
||||||
return func(t *testing.T, e *peerAPITestEnv) {
|
return func(t *testing.T, e *peerAPITestEnv) {
|
||||||
root := e.ph.ps.rootDir
|
root := e.ph.ps.taildrop.RootDir
|
||||||
if root == "" {
|
if root == "" {
|
||||||
t.Errorf("no rootdir; can't check whether %q has size %v", name, size)
|
t.Errorf("no rootdir; can't check whether %q has size %v", name, size)
|
||||||
return
|
return
|
||||||
@ -83,7 +84,7 @@ func fileHasSize(name string, size int) check {
|
|||||||
|
|
||||||
func fileHasContents(name string, want string) check {
|
func fileHasContents(name string, want string) check {
|
||||||
return func(t *testing.T, e *peerAPITestEnv) {
|
return func(t *testing.T, e *peerAPITestEnv) {
|
||||||
root := e.ph.ps.rootDir
|
root := e.ph.ps.taildrop.RootDir
|
||||||
if root == "" {
|
if root == "" {
|
||||||
t.Errorf("no rootdir; can't check contents of %q", name)
|
t.Errorf("no rootdir; can't check contents of %q", name)
|
||||||
return
|
return
|
||||||
@ -492,7 +493,10 @@ func TestHandlePeerAPI(t *testing.T) {
|
|||||||
var rootDir string
|
var rootDir string
|
||||||
if !tt.omitRoot {
|
if !tt.omitRoot {
|
||||||
rootDir = t.TempDir()
|
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 {
|
for _, req := range tt.reqs {
|
||||||
e.rr = httptest.NewRecorder()
|
e.rr = httptest.NewRecorder()
|
||||||
@ -531,7 +535,11 @@ func TestFileDeleteRace(t *testing.T) {
|
|||||||
capFileSharing: true,
|
capFileSharing: true,
|
||||||
clock: &tstest.Clock{},
|
clock: &tstest.Clock{},
|
||||||
},
|
},
|
||||||
rootDir: dir,
|
taildrop: &taildrop.Handler{
|
||||||
|
Logf: t.Logf,
|
||||||
|
Clock: &tstest.Clock{},
|
||||||
|
RootDir: dir,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
ph := &peerAPIHandler{
|
ph := &peerAPIHandler{
|
||||||
isSelf: true,
|
isSelf: true,
|
||||||
@ -550,7 +558,7 @@ func TestFileDeleteRace(t *testing.T) {
|
|||||||
if res := rr.Result(); res.StatusCode != 200 {
|
if res := rr.Result(); res.StatusCode != 200 {
|
||||||
t.Fatal(res.Status)
|
t.Fatal(res.Status)
|
||||||
}
|
}
|
||||||
wfs, err := ps.WaitingFiles()
|
wfs, err := ps.taildrop.WaitingFiles()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -558,10 +566,10 @@ func TestFileDeleteRace(t *testing.T) {
|
|||||||
t.Fatalf("waiting files = %d; want 1", len(wfs))
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wfs, err = ps.WaitingFiles()
|
wfs, err = ps.taildrop.WaitingFiles()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -579,19 +587,21 @@ func TestDeletedMarkers(t *testing.T) {
|
|||||||
logf: t.Logf,
|
logf: t.Logf,
|
||||||
capFileSharing: true,
|
capFileSharing: true,
|
||||||
},
|
},
|
||||||
rootDir: dir,
|
taildrop: &taildrop.Handler{
|
||||||
|
RootDir: dir,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
nothingWaiting := func() {
|
nothingWaiting := func() {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ps.knownEmpty.Store(false)
|
ps.taildrop.KnownEmpty.Store(false)
|
||||||
if ps.hasFilesWaiting() {
|
if ps.taildrop.HasFilesWaiting() {
|
||||||
t.Fatal("unexpected files waiting")
|
t.Fatal("unexpected files waiting")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
touch := func(base string) {
|
touch := func(base string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := touchFile(filepath.Join(dir, base)); err != nil {
|
if err := taildrop.TouchFile(filepath.Join(dir, base)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -620,7 +630,7 @@ func TestDeletedMarkers(t *testing.T) {
|
|||||||
|
|
||||||
touch("foo.jpg.deleted")
|
touch("foo.jpg.deleted")
|
||||||
touch("foo.jpg")
|
touch("foo.jpg")
|
||||||
wf, err := ps.WaitingFiles()
|
wf, err := ps.taildrop.WaitingFiles()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -631,7 +641,7 @@ func TestDeletedMarkers(t *testing.T) {
|
|||||||
|
|
||||||
touch("foo.jpg.deleted")
|
touch("foo.jpg.deleted")
|
||||||
touch("foo.jpg")
|
touch("foo.jpg")
|
||||||
if rc, _, err := ps.OpenFile("foo.jpg"); err == nil {
|
if rc, _, err := ps.taildrop.OpenFile("foo.jpg"); err == nil {
|
||||||
rc.Close()
|
rc.Close()
|
||||||
t.Fatal("unexpected foo.jpg open")
|
t.Fatal("unexpected foo.jpg open")
|
||||||
}
|
}
|
||||||
@ -640,14 +650,14 @@ func TestDeletedMarkers(t *testing.T) {
|
|||||||
// And verify basics still work in non-deleted cases.
|
// And verify basics still work in non-deleted cases.
|
||||||
touch("foo.jpg")
|
touch("foo.jpg")
|
||||||
touch("bar.jpg.deleted")
|
touch("bar.jpg.deleted")
|
||||||
if wf, err := ps.WaitingFiles(); err != nil {
|
if wf, err := ps.taildrop.WaitingFiles(); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
} else if len(wf) != 1 {
|
} else if len(wf) != 1 {
|
||||||
t.Errorf("WaitingFiles = %d; want 1", len(wf))
|
t.Errorf("WaitingFiles = %d; want 1", len(wf))
|
||||||
} else if wf[0].Name != "foo.jpg" {
|
} else if wf[0].Name != "foo.jpg" {
|
||||||
t.Errorf("unexpected waiting file %+v", wf[0])
|
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)
|
t.Fatal(err)
|
||||||
} else {
|
} else {
|
||||||
rc.Close()
|
rc.Close()
|
||||||
@ -756,7 +766,7 @@ func TestRedactErr(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Run("Root", func(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 {
|
if got != tc.want {
|
||||||
t.Errorf("err = %q; want %q", 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())
|
wrapped := fmt.Errorf("wrapped error: %w", tc.err())
|
||||||
want := "wrapped error: " + tc.want
|
want := "wrapped error: " + tc.want
|
||||||
|
|
||||||
got := redactErr(wrapped).Error()
|
got := taildrop.RedactErr(wrapped).Error()
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("err = %q; want %q", got, want)
|
t.Errorf("err = %q; want %q", got, want)
|
||||||
}
|
}
|
||||||
|
253
taildrop/receiver.go
Normal file
253
taildrop/receiver.go
Normal file
@ -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
|
||||||
|
}
|
178
taildrop/taildrop.go
Normal file
178
taildrop/taildrop.go
Normal file
@ -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}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user