diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index c02e7d5ba..bc450f4aa 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -235,7 +235,7 @@ type LocalBackend struct { interact bool egg bool prevIfState *interfaces.State - peerAPIServer *peerAPIServer // or nil + peerAPIServer *PeerAPIServer // or nil peerAPIListeners []*peerAPIListener loginFlags controlclient.LoginFlags incomingFiles map[*incomingFile]bool @@ -3545,7 +3545,7 @@ func (b *LocalBackend) initPeerAPIListener() { b.logf("peerapi starting without Taildrop directory configured") } - ps := &peerAPIServer{ + ps := &PeerAPIServer{ b: b, rootDir: fileRoot, directFileMode: b.directFileRoot != "", diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index d9801d90d..4ce43f7c0 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/ipn/ipnlocal/peerapi.go @@ -9,18 +9,13 @@ 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" @@ -29,18 +24,14 @@ import ( "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" @@ -49,8 +40,6 @@ import ( "tailscale.com/tailcfg" "tailscale.com/types/views" "tailscale.com/util/clientmetric" - "tailscale.com/util/multierr" - "tailscale.com/version/distro" "tailscale.com/wgengine/filter" ) @@ -60,7 +49,7 @@ var initListenConfig func(*net.ListenConfig, netip.Addr, *interfaces.State, stri // ("cleartext" HTTP/2) support to the peerAPI. var addH2C func(*http.Server) -type peerAPIServer struct { +type PeerAPIServer struct { b *LocalBackend rootDir string // empty means file receiving unavailable knownEmpty atomic.Bool @@ -82,373 +71,7 @@ type peerAPIServer struct { 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 ( - 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. // But since we started intercepting it with netstack, it's not even important that // we have a real kernel-level listener. So just create a dummy listener on Android @@ -511,7 +134,7 @@ func (s *peerAPIServer) listen(ip netip.Addr, ifState *interfaces.State) (ln net } type peerAPIListener struct { - ps *peerAPIServer + ps *PeerAPIServer ip netip.Addr lb *LocalBackend @@ -578,7 +201,7 @@ func (pln *peerAPIListener) ServeConn(src netip.AddrPort, c net.Conn) { c.Close() return } - h := &peerAPIHandler{ + h := &PeerAPIHandler{ ps: pln.ps, isSelf: nm.SelfNode.User() == peerNode.User(), remoteAddr: src, @@ -595,9 +218,9 @@ func (pln *peerAPIListener) ServeConn(src netip.AddrPort, c net.Conn) { go httpServer.Serve(netutil.NewOneConnListener(c, nil)) } -// peerAPIHandler serves the PeerAPI for a source specific client. -type peerAPIHandler struct { - ps *peerAPIServer +// PeerAPIHandler serves the PeerAPI for a source specific client. +type PeerAPIHandler struct { + ps *PeerAPIServer remoteAddr netip.AddrPort isSelf bool // whether peerNode is owned by same user as this node selfNode tailcfg.NodeView // this node; always non-nil @@ -605,13 +228,13 @@ type peerAPIHandler struct { peerUser tailcfg.UserProfile // profile of peerNode } -func (h *peerAPIHandler) logf(format string, a ...any) { +func (h *PeerAPIHandler) logf(format string, a ...any) { h.ps.b.logf("peerapi: "+format, a...) } // isAddressValid reports whether addr is a valid destination address for this // node originating from the peer. -func (h *peerAPIHandler) isAddressValid(addr netip.Addr) bool { +func (h *PeerAPIHandler) isAddressValid(addr netip.Addr) bool { if v := h.peerNode.SelfNodeV4MasqAddrForThisPeer(); v != nil { return *v == addr } @@ -622,7 +245,7 @@ func (h *peerAPIHandler) isAddressValid(addr netip.Addr) bool { return views.SliceContains(h.selfNode.Addresses(), pfx) } -func (h *peerAPIHandler) validateHost(r *http.Request) error { +func (h *PeerAPIHandler) validateHost(r *http.Request) error { if r.Host == "peer" { return nil } @@ -636,7 +259,7 @@ func (h *peerAPIHandler) validateHost(r *http.Request) error { return nil } -func (h *peerAPIHandler) validatePeerAPIRequest(r *http.Request) error { +func (h *PeerAPIHandler) validatePeerAPIRequest(r *http.Request) error { if r.Referer() != "" { return errors.New("unexpected Referer") } @@ -679,7 +302,7 @@ func peerAPIRequestShouldGetSecurityHeaders(r *http.Request) bool { return false } -func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (h *PeerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if err := h.validatePeerAPIRequest(r); err != nil { metricInvalidRequests.Add(1) h.logf("invalid request from %v: %v", h.remoteAddr, err) @@ -693,6 +316,7 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } if strings.HasPrefix(r.URL.Path, "/v0/put/") { metricPutCalls.Add(1) + //TODO: fix this h.handlePeerPut(w, r) return } @@ -747,7 +371,7 @@ This is my Tailscale device. Your device is %v. } } -func (h *peerAPIHandler) handleServeIngress(w http.ResponseWriter, r *http.Request) { +func (h *PeerAPIHandler) handleServeIngress(w http.ResponseWriter, r *http.Request) { // http.Errors only useful if hitting endpoint manually // otherwise rely on log lines when debugging ingress connections // as connection is hijacked for bidi and is encrypted tls @@ -808,7 +432,7 @@ func (h *peerAPIHandler) handleServeIngress(w http.ResponseWriter, r *http.Reque h.ps.b.HandleIngressTCPConn(h.peerNode, target, srcAddr, getConnOrReset, sendRST) } -func (h *peerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Request) { +func (h *PeerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Request) { if !h.canDebug() { http.Error(w, "denied; no debug access", http.StatusForbidden) return @@ -855,7 +479,7 @@ func (h *peerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Re fmt.Fprintln(w, "") } -func (h *peerAPIHandler) handleServeDoctor(w http.ResponseWriter, r *http.Request) { +func (h *PeerAPIHandler) handleServeDoctor(w http.ResponseWriter, r *http.Request) { if !h.canDebug() { http.Error(w, "denied; no debug access", http.StatusForbidden) return @@ -873,7 +497,7 @@ func (h *peerAPIHandler) handleServeDoctor(w http.ResponseWriter, r *http.Reques fmt.Fprintln(w, "") } -func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Request) { +func (h *PeerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Request) { if !h.canDebug() { http.Error(w, "denied; no debug access", http.StatusForbidden) return @@ -971,76 +595,9 @@ func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Req fmt.Fprintln(w, "") } -type incomingFile struct { - name string // "foo.jpg" - started time.Time - size int64 // or -1 if unknown; never 0 - w io.Writer // underlying writer - ph *peerAPIHandler - partialPath string // non-empty in direct mode - - mu sync.Mutex - copied int64 - done bool - lastNotify time.Time -} - -func (f *incomingFile) markAndNotifyDone() { - f.mu.Lock() - f.done = true - f.mu.Unlock() - b := f.ph.ps.b - b.sendFileNotify() -} - -func (f *incomingFile) Write(p []byte) (n int, err error) { - n, err = f.w.Write(p) - - b := f.ph.ps.b - var needNotify bool - defer func() { - if needNotify { - b.sendFileNotify() - } - }() - if n > 0 { - f.mu.Lock() - defer f.mu.Unlock() - f.copied += int64(n) - now := b.clock.Now() - if f.lastNotify.IsZero() || now.Sub(f.lastNotify) > time.Second { - f.lastNotify = now - needNotify = true - } - } - return n, err -} - -func (f *incomingFile) PartialFile() ipn.PartialFile { - f.mu.Lock() - defer f.mu.Unlock() - return ipn.PartialFile{ - Name: f.name, - Started: f.started, - DeclaredSize: f.size, - Received: f.copied, - PartialPath: f.partialPath, - Done: f.done, - } -} - -// canPutFile reports whether h can put a file ("Taildrop") to this node. -func (h *peerAPIHandler) canPutFile() bool { - if h.peerNode.UnsignedPeerAPIOnly() { - // Unsigned peers can't send files. - return false - } - return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityFileSharingSend) -} - // canDebug reports whether h can debug this node (goroutines, metrics, // magicsock internal state, etc). -func (h *peerAPIHandler) canDebug() bool { +func (h *PeerAPIHandler) canDebug() bool { if !h.selfNode.HasCap(tailcfg.CapabilityDebug) { // This node does not expose debug info. return false @@ -1053,7 +610,7 @@ func (h *peerAPIHandler) canDebug() bool { } // canWakeOnLAN reports whether h can send a Wake-on-LAN packet from this node. -func (h *peerAPIHandler) canWakeOnLAN() bool { +func (h *PeerAPIHandler) canWakeOnLAN() bool { if h.peerNode.UnsignedPeerAPIOnly() { return false } @@ -1063,150 +620,15 @@ func (h *peerAPIHandler) canWakeOnLAN() bool { var allowSelfIngress = envknob.RegisterBool("TS_ALLOW_SELF_INGRESS") // canIngress reports whether h can send ingress requests to this node. -func (h *peerAPIHandler) canIngress() bool { +func (h *PeerAPIHandler) canIngress() bool { return h.peerHasCap(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf) } -func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool { +func (h *PeerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool { return h.ps.b.PeerCaps(h.remoteAddr.Addr()).HasCapability(wantCap) } -func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) { - if !envknob.CanTaildrop() { - http.Error(w, "Taildrop disabled on device", http.StatusForbidden) - return - } - if !h.canPutFile() { - http.Error(w, "Taildrop access denied", http.StatusForbidden) - return - } - if !h.ps.b.hasCapFileSharing() { - http.Error(w, "file sharing not enabled by Tailscale admin", http.StatusForbidden) - return - } - if r.Method != "PUT" { - http.Error(w, "expected method PUT", http.StatusMethodNotAllowed) - return - } - if h.ps.rootDir == "" { - http.Error(w, errNoTaildrop.Error(), http.StatusInternalServerError) - return - } - if distro.Get() == distro.Unraid && !h.ps.directFileMode { - http.Error(w, "Taildrop folder not configured or accessible", http.StatusInternalServerError) - return - } - rawPath := r.URL.EscapedPath() - suffix, ok := strings.CutPrefix(rawPath, "/v0/put/") - if !ok { - http.Error(w, "misconfigured internals", 500) - return - } - if suffix == "" { - http.Error(w, "empty filename", 400) - return - } - if strings.Contains(suffix, "/") { - http.Error(w, "directories not supported", 400) - return - } - baseName, err := url.PathUnescape(suffix) - if err != nil { - http.Error(w, "bad path encoding", 400) - return - } - dstFile, ok := h.ps.diskPath(baseName) - if !ok { - http.Error(w, "bad filename", 400) - return - } - t0 := h.ps.b.clock.Now() - // TODO(bradfitz): prevent same filename being sent by two peers at once - - // prevent same filename being sent twice - if _, err := os.Stat(dstFile); err == nil { - http.Error(w, "file exists", http.StatusConflict) - return - } - - partialFile := dstFile + partialSuffix - f, err := os.Create(partialFile) - if err != nil { - h.logf("put Create error: %v", redactErr(err)) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - var success bool - defer func() { - if !success { - os.Remove(partialFile) - } - }() - var finalSize int64 - var inFile *incomingFile - if r.ContentLength != 0 { - inFile = &incomingFile{ - name: baseName, - started: h.ps.b.clock.Now(), - size: r.ContentLength, - w: f, - ph: h, - } - if h.ps.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) - f.Close() - h.logf("put Copy error: %v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - finalSize = n - } - if err := 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 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) - h.logf("put final rename: %v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } - - d := h.ps.b.clock.Since(t0).Round(time.Second / 10) - h.logf("got put of %s in %v from %v/%v", approxSize(finalSize), d, h.remoteAddr.Addr(), h.peerNode.ComputedName) - - // TODO: set modtime - // TODO: some real response - success = true - io.WriteString(w, "{}\n") - h.ps.knownEmpty.Store(false) - h.ps.b.sendFileNotify() -} - -func approxSize(n int64) string { - if n <= 1<<10 { - return "<=1KB" - } - if n <= 1<<20 { - return "<=1MB" - } - return fmt.Sprintf("~%dMB", n>>20) -} - -func (h *peerAPIHandler) handleServeGoroutines(w http.ResponseWriter, r *http.Request) { +func (h *PeerAPIHandler) handleServeGoroutines(w http.ResponseWriter, r *http.Request) { if !h.canDebug() { http.Error(w, "denied; no debug access", http.StatusForbidden) return @@ -1222,7 +644,7 @@ func (h *peerAPIHandler) handleServeGoroutines(w http.ResponseWriter, r *http.Re w.Write(buf) } -func (h *peerAPIHandler) handleServeEnv(w http.ResponseWriter, r *http.Request) { +func (h *PeerAPIHandler) handleServeEnv(w http.ResponseWriter, r *http.Request) { if !h.canDebug() { http.Error(w, "denied; no debug access", http.StatusForbidden) return @@ -1242,7 +664,7 @@ func (h *peerAPIHandler) handleServeEnv(w http.ResponseWriter, r *http.Request) json.NewEncoder(w).Encode(data) } -func (h *peerAPIHandler) handleServeMagicsock(w http.ResponseWriter, r *http.Request) { +func (h *PeerAPIHandler) handleServeMagicsock(w http.ResponseWriter, r *http.Request) { if !h.canDebug() { http.Error(w, "denied; no debug access", http.StatusForbidden) return @@ -1250,7 +672,7 @@ func (h *peerAPIHandler) handleServeMagicsock(w http.ResponseWriter, r *http.Req h.ps.b.magicConn().ServeHTTPDebug(w, r) } -func (h *peerAPIHandler) handleServeMetrics(w http.ResponseWriter, r *http.Request) { +func (h *PeerAPIHandler) handleServeMetrics(w http.ResponseWriter, r *http.Request) { if !h.canDebug() { http.Error(w, "denied; no debug access", http.StatusForbidden) return @@ -1259,7 +681,7 @@ func (h *peerAPIHandler) handleServeMetrics(w http.ResponseWriter, r *http.Reque clientmetric.WritePrometheusExpositionFormat(w) } -func (h *peerAPIHandler) handleServeDNSFwd(w http.ResponseWriter, r *http.Request) { +func (h *PeerAPIHandler) handleServeDNSFwd(w http.ResponseWriter, r *http.Request) { if !h.canDebug() { http.Error(w, "denied; no debug access", http.StatusForbidden) return @@ -1272,7 +694,7 @@ func (h *peerAPIHandler) handleServeDNSFwd(w http.ResponseWriter, r *http.Reques dh.ServeHTTP(w, r) } -func (h *peerAPIHandler) handleWakeOnLAN(w http.ResponseWriter, r *http.Request) { +func (h *PeerAPIHandler) handleWakeOnLAN(w http.ResponseWriter, r *http.Request) { if !h.canWakeOnLAN() { http.Error(w, "no WoL access", http.StatusForbidden) return @@ -1327,7 +749,7 @@ func (h *peerAPIHandler) handleWakeOnLAN(w http.ResponseWriter, r *http.Request) json.NewEncoder(w).Encode(res) } -func (h *peerAPIHandler) replyToDNSQueries() bool { +func (h *PeerAPIHandler) replyToDNSQueries() bool { if h.isSelf { // If the peer is owned by the same user, just allow it // without further checks. @@ -1371,7 +793,7 @@ func (h *peerAPIHandler) replyToDNSQueries() bool { // handleDNSQuery implements a DoH server (RFC 8484) over the peerapi. // It's not over HTTPS as the spec dictates, but rather HTTP-over-WireGuard. -func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request) { +func (h *PeerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request) { if h.ps.resolver == nil { http.Error(w, "DNS not wired up", http.StatusNotImplemented) return diff --git a/ipn/ipnlocal/peerapi_test.go b/ipn/ipnlocal/peerapi_test.go index 2dddc9ad6..c9e3e96b3 100644 --- a/ipn/ipnlocal/peerapi_test.go +++ b/ipn/ipnlocal/peerapi_test.go @@ -32,7 +32,7 @@ import ( ) type peerAPITestEnv struct { - ph *peerAPIHandler + ph *PeerAPIHandler rr *httptest.ResponseRecorder logBuf tstest.MemLogger } @@ -479,13 +479,13 @@ func TestHandlePeerAPI(t *testing.T) { netMap: &netmap.NetworkMap{SelfNode: selfNode.View()}, clock: &tstest.Clock{}, } - e.ph = &peerAPIHandler{ + e.ph = &PeerAPIHandler{ isSelf: tt.isSelf, selfNode: selfNode.View(), peerNode: (&tailcfg.Node{ ComputedName: "some-peer-name", }).View(), - ps: &peerAPIServer{ + ps: &PeerAPIServer{ b: lb, }, } @@ -525,7 +525,7 @@ func TestHandlePeerAPI(t *testing.T) { // a bit. So test that we work around that sufficiently. func TestFileDeleteRace(t *testing.T) { dir := t.TempDir() - ps := &peerAPIServer{ + ps := &PeerAPIServer{ b: &LocalBackend{ logf: t.Logf, capFileSharing: true, @@ -533,7 +533,7 @@ func TestFileDeleteRace(t *testing.T) { }, rootDir: dir, } - ph := &peerAPIHandler{ + ph := &PeerAPIHandler{ isSelf: true, peerNode: (&tailcfg.Node{ ComputedName: "some-peer-name", @@ -574,7 +574,7 @@ func TestFileDeleteRace(t *testing.T) { // Tests "foo.jpg.deleted" marks (for Windows). func TestDeletedMarkers(t *testing.T) { dir := t.TempDir() - ps := &peerAPIServer{ + ps := &PeerAPIServer{ b: &LocalBackend{ logf: t.Logf, capFileSharing: true, @@ -656,7 +656,7 @@ func TestDeletedMarkers(t *testing.T) { } func TestPeerAPIReplyToDNSQueries(t *testing.T) { - var h peerAPIHandler + var h PeerAPIHandler h.isSelf = true if !h.replyToDNSQueries() { @@ -667,7 +667,7 @@ func TestPeerAPIReplyToDNSQueries(t *testing.T) { eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0) pm := must.Get(newProfileManager(new(mem.Store), t.Logf)) - h.ps = &peerAPIServer{ + h.ps = &PeerAPIServer{ b: &LocalBackend{ e: eng, pm: pm, diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index e68e6adaf..0b8949478 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -15,7 +15,6 @@ import ( "io" "net" "net/http" - "net/http/httputil" "net/netip" "net/url" "runtime" @@ -859,7 +858,7 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) { } configIn := new(ipn.ServeConfig) if err := json.NewDecoder(r.Body).Decode(configIn); err != nil { - writeErrorJSON(w, fmt.Errorf("decoding config: %w", err)) + WriteErrorJSON(w, fmt.Errorf("decoding config: %w", err)) return } etag := r.Header.Get("If-Match") @@ -868,7 +867,7 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusPreconditionFailed) return } - writeErrorJSON(w, fmt.Errorf("updating config: %w", err)) + WriteErrorJSON(w, fmt.Errorf("updating config: %w", err)) return } w.WriteHeader(http.StatusOK) @@ -1119,67 +1118,7 @@ func (h *Handler) serveCheckPrefs(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(res) } -func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) { - if !h.PermitWrite { - http.Error(w, "file access denied", http.StatusForbidden) - return - } - suffix, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/files/") - if !ok { - http.Error(w, "misconfigured", http.StatusInternalServerError) - return - } - if suffix == "" { - if r.Method != "GET" { - http.Error(w, "want GET to list files", 400) - return - } - ctx := r.Context() - if s := r.FormValue("waitsec"); s != "" && s != "0" { - d, err := strconv.Atoi(s) - if err != nil { - http.Error(w, "invalid waitsec", http.StatusBadRequest) - return - } - deadline := time.Now().Add(time.Duration(d) * time.Second) - var cancel context.CancelFunc - ctx, cancel = context.WithDeadline(ctx, deadline) - defer cancel() - } - wfs, err := h.b.AwaitWaitingFiles(ctx) - if err != nil && ctx.Err() == nil { - http.Error(w, err.Error(), 500) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(wfs) - return - } - name, err := url.PathUnescape(suffix) - if err != nil { - http.Error(w, "bad filename", 400) - return - } - if r.Method == "DELETE" { - if err := h.b.DeleteFile(name); err != nil { - http.Error(w, err.Error(), 500) - return - } - w.WriteHeader(http.StatusNoContent) - return - } - rc, size, err := h.b.OpenFile(name) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - defer rc.Close() - w.Header().Set("Content-Length", fmt.Sprint(size)) - w.Header().Set("Content-Type", "application/octet-stream") - io.Copy(w, rc) -} - -func writeErrorJSON(w http.ResponseWriter, err error) { +func WriteErrorJSON(w http.ResponseWriter, err error) { if err == nil { err = errors.New("unexpected nil error") } @@ -1191,101 +1130,6 @@ func writeErrorJSON(w http.ResponseWriter, err error) { json.NewEncoder(w).Encode(E{err.Error()}) } -func (h *Handler) serveFileTargets(w http.ResponseWriter, r *http.Request) { - if !h.PermitRead { - http.Error(w, "access denied", http.StatusForbidden) - return - } - if r.Method != "GET" { - http.Error(w, "want GET to list targets", 400) - return - } - fts, err := h.b.FileTargets() - if err != nil { - writeErrorJSON(w, err) - return - } - mak.NonNilSliceForJSON(&fts) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(fts) -} - -// serveFilePut sends a file to another node. -// -// It's sometimes possible for clients to do this themselves, without -// tailscaled, except in the case of tailscaled running in -// userspace-networking ("netstack") mode, in which case tailscaled -// needs to a do a netstack dial out. -// -// Instead, the CLI also goes through tailscaled so it doesn't need to be -// aware of the network mode in use. -// -// macOS/iOS have always used this localapi method to simplify the GUI -// clients. -// -// The Windows client currently (2021-11-30) uses the peerapi (/v0/put/) -// directly, as the Windows GUI always runs in tun mode anyway. -// -// URL format: -// -// - PUT /localapi/v0/file-put/:stableID/:escaped-filename -func (h *Handler) serveFilePut(w http.ResponseWriter, r *http.Request) { - metricFilePutCalls.Add(1) - - if !h.PermitWrite { - http.Error(w, "file access denied", http.StatusForbidden) - return - } - if r.Method != "PUT" { - http.Error(w, "want PUT to put file", 400) - return - } - fts, err := h.b.FileTargets() - if err != nil { - http.Error(w, err.Error(), 500) - return - } - - upath, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/file-put/") - if !ok { - http.Error(w, "misconfigured", http.StatusInternalServerError) - return - } - stableIDStr, filenameEscaped, ok := strings.Cut(upath, "/") - if !ok { - http.Error(w, "bogus URL", 400) - return - } - stableID := tailcfg.StableNodeID(stableIDStr) - - var ft *apitype.FileTarget - for _, x := range fts { - if x.Node.StableID == stableID { - ft = x - break - } - } - if ft == nil { - http.Error(w, "node not found", 404) - return - } - dstURL, err := url.Parse(ft.PeerAPIURL) - if err != nil { - http.Error(w, "bogus peer URL", 500) - return - } - outReq, err := http.NewRequestWithContext(r.Context(), "PUT", "http://peer/v0/put/"+filenameEscaped, r.Body) - if err != nil { - http.Error(w, "bogus outreq", 500) - return - } - outReq.ContentLength = r.ContentLength - - rp := httputil.NewSingleHostReverseProxy(dstURL) - rp.Transport = h.b.Dialer().PeerAPITransport() - rp.ServeHTTP(w, outReq) -} - func (h *Handler) serveSetDNS(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "access denied", http.StatusForbidden) @@ -1298,7 +1142,7 @@ func (h *Handler) serveSetDNS(w http.ResponseWriter, r *http.Request) { ctx := r.Context() err := h.b.SetDNS(ctx, r.FormValue("name"), r.FormValue("value")) if err != nil { - writeErrorJSON(w, err) + WriteErrorJSON(w, err) return } w.Header().Set("Content-Type", "application/json") @@ -1389,7 +1233,7 @@ func (h *Handler) servePing(w http.ResponseWriter, r *http.Request) { } res, err := h.b.Ping(ctx, ip, tailcfg.PingType(pingTypeStr), size) if err != nil { - writeErrorJSON(w, err) + WriteErrorJSON(w, err) return } w.Header().Set("Content-Type", "application/json") diff --git a/taildrop/reciever.go b/taildrop/reciever.go new file mode 100644 index 000000000..01b516f8a --- /dev/null +++ b/taildrop/reciever.go @@ -0,0 +1,609 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package taildrop + +import ( + "context" + "errors" + "hash/adler32" + "io" + "io/fs" + "net/http" + "os" + "path" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "time" + "unicode" + "unicode/utf8" + + "tailscale.com/client/tailscale/apitype" + "tailscale.com/envknob" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/logtail/backoff" + "tailscale.com/tailcfg" + "tailscale.com/util/multierr" + "tailscale.com/version/distro" +) + +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 approxSize(n int64) string { + if n <= 1<<10 { + return "<=1KB" + } + if n <= 1<<20 { + return "<=1MB" + } + return fmt.Sprintf("~%dMB", n>>20) +} + +type peerAPIServer struct { + *ipnlocal.PeerAPIServer +} + +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 +} + +// 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} +} + +type peerAPIHandler struct { + *ipnlocal.PeerAPIHandler +} + +// 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 ( + 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 + } +} + +// 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 +} + +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 +} + +type incomingFile struct { + name string // "foo.jpg" + started time.Time + size int64 // or -1 if unknown; never 0 + w io.Writer // underlying writer + ph ipnlocal.PeerAPIHandler + partialPath string // non-empty in direct mode + + mu sync.Mutex + copied int64 + done bool + lastNotify time.Time +} + +func (f *incomingFile) markAndNotifyDone() { + f.mu.Lock() + f.done = true + f.mu.Unlock() + b := f.ph.ps.b + b.sendFileNotify() +} + +func (f *incomingFile) Write(p []byte) (n int, err error) { + n, err = f.w.Write(p) + + b := f.ph.ps.b + var needNotify bool + defer func() { + if needNotify { + b.sendFileNotify() + } + }() + if n > 0 { + f.mu.Lock() + defer f.mu.Unlock() + f.copied += int64(n) + now := b.clock.Now() + if f.lastNotify.IsZero() || now.Sub(f.lastNotify) > time.Second { + f.lastNotify = now + needNotify = true + } + } + return n, err +} + +func (f *incomingFile) PartialFile() ipn.PartialFile { + f.mu.Lock() + defer f.mu.Unlock() + return ipn.PartialFile{ + Name: f.name, + Started: f.started, + DeclaredSize: f.size, + Received: f.copied, + PartialPath: f.partialPath, + Done: f.done, + } +} + +// canPutFile reports whether h can put a file ("Taildrop") to this node. +func (h *peerAPIHandler) canPutFile() bool { + if h.peerNode.UnsignedPeerAPIOnly() { + // Unsigned peers can't send files. + return false + } + return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityFileSharingSend) +} + +func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) { + if !envknob.CanTaildrop() { + http.Error(w, "Taildrop disabled on device", http.StatusForbidden) + return + } + if !h.canPutFile() { + http.Error(w, "Taildrop access denied", http.StatusForbidden) + return + } + if !h.ps.b.hasCapFileSharing() { + http.Error(w, "file sharing not enabled by Tailscale admin", http.StatusForbidden) + return + } + if r.Method != "PUT" { + http.Error(w, "expected method PUT", http.StatusMethodNotAllowed) + return + } + if h.ps.rootDir == "" { + http.Error(w, errNoTaildrop.Error(), http.StatusInternalServerError) + return + } + if distro.Get() == distro.Unraid && !h.ps.directFileMode { + http.Error(w, "Taildrop folder not configured or accessible", http.StatusInternalServerError) + return + } + rawPath := r.URL.EscapedPath() + suffix, ok := strings.CutPrefix(rawPath, "/v0/put/") + if !ok { + http.Error(w, "misconfigured internals", 500) + return + } + if suffix == "" { + http.Error(w, "empty filename", 400) + return + } + if strings.Contains(suffix, "/") { + http.Error(w, "directories not supported", 400) + return + } + baseName, err := url.PathUnescape(suffix) + if err != nil { + http.Error(w, "bad path encoding", 400) + return + } + dstFile, ok := h.ps.diskPath(baseName) + if !ok { + http.Error(w, "bad filename", 400) + return + } + t0 := h.ps.b.clock.Now() + // TODO(bradfitz): prevent same filename being sent by two peers at once + + // prevent same filename being sent twice + if _, err := os.Stat(dstFile); err == nil { + http.Error(w, "file exists", http.StatusConflict) + return + } + + partialFile := dstFile + partialSuffix + f, err := os.Create(partialFile) + if err != nil { + h.logf("put Create error: %v", redactErr(err)) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var success bool + defer func() { + if !success { + os.Remove(partialFile) + } + }() + var finalSize int64 + var inFile *incomingFile + if r.ContentLength != 0 { + inFile = &incomingFile{ + name: baseName, + started: h.ps.b.clock.Now(), + size: r.ContentLength, + w: f, + ph: h, + } + if h.ps.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) + f.Close() + h.logf("put Copy error: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + finalSize = n + } + if err := 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 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) + h.logf("put final rename: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + d := h.ps.b.clock.Since(t0).Round(time.Second / 10) + h.logf("got put of %s in %v from %v/%v", approxSize(finalSize), d, h.remoteAddr.Addr(), h.peerNode.ComputedName) + + // TODO: set modtime + // TODO: some real response + success = true + io.WriteString(w, "{}\n") + h.ps.knownEmpty.Store(false) + h.ps.b.sendFileNotify() +} diff --git a/taildrop/sender.go b/taildrop/sender.go new file mode 100644 index 000000000..90e676a5a --- /dev/null +++ b/taildrop/sender.go @@ -0,0 +1,181 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package taildrop + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "time" + + "tailscale.com/client/tailscale/apitype" + "tailscale.com/ipn/localapi" + "tailscale.com/tailcfg" + "tailscale.com/util/mak" +) + +type Handler struct { + *localapi.Handler +} + +func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "file access denied", http.StatusForbidden) + return + } + suffix, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/files/") + if !ok { + http.Error(w, "misconfigured", http.StatusInternalServerError) + return + } + if suffix == "" { + if r.Method != "GET" { + http.Error(w, "want GET to list files", 400) + return + } + ctx := r.Context() + if s := r.FormValue("waitsec"); s != "" && s != "0" { + d, err := strconv.Atoi(s) + if err != nil { + http.Error(w, "invalid waitsec", http.StatusBadRequest) + return + } + deadline := time.Now().Add(time.Duration(d) * time.Second) + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, deadline) + defer cancel() + } + wfs, err := h.b.AwaitWaitingFiles(ctx) + if err != nil && ctx.Err() == nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(wfs) + return + } + name, err := url.PathUnescape(suffix) + if err != nil { + http.Error(w, "bad filename", 400) + return + } + if r.Method == "DELETE" { + if err := h.b.DeleteFile(name); err != nil { + http.Error(w, err.Error(), 500) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + rc, size, err := h.b.OpenFile(name) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer rc.Close() + w.Header().Set("Content-Length", fmt.Sprint(size)) + w.Header().Set("Content-Type", "application/octet-stream") + io.Copy(w, rc) +} + +func (h *Handler) serveFileTargets(w http.ResponseWriter, r *http.Request) { + if !h.PermitRead { + http.Error(w, "access denied", http.StatusForbidden) + return + } + if r.Method != "GET" { + http.Error(w, "want GET to list targets", 400) + return + } + fts, err := h.b.FileTargets() + if err != nil { + WriteErrorJSON(w, err) + return + } + mak.NonNilSliceForJSON(&fts) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(fts) +} + +// serveFilePut sends a file to another node. +// +// It's sometimes possible for clients to do this themselves, without +// tailscaled, except in the case of tailscaled running in +// userspace-networking ("netstack") mode, in which case tailscaled +// needs to a do a netstack dial out. +// +// Instead, the CLI also goes through tailscaled so it doesn't need to be +// aware of the network mode in use. +// +// macOS/iOS have always used this localapi method to simplify the GUI +// clients. +// +// The Windows client currently (2021-11-30) uses the peerapi (/v0/put/) +// directly, as the Windows GUI always runs in tun mode anyway. +// +// URL format: +// +// - PUT /localapi/v0/file-put/:stableID/:escaped-filename +func (h *Handler) serveFilePut(w http.ResponseWriter, r *http.Request) { + metricFilePutCalls.Add(1) + + if !h.PermitWrite { + http.Error(w, "file access denied", http.StatusForbidden) + return + } + if r.Method != "PUT" { + http.Error(w, "want PUT to put file", 400) + return + } + fts, err := h.b.FileTargets() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + + upath, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/file-put/") + if !ok { + http.Error(w, "misconfigured", http.StatusInternalServerError) + return + } + stableIDStr, filenameEscaped, ok := strings.Cut(upath, "/") + if !ok { + http.Error(w, "bogus URL", 400) + return + } + stableID := tailcfg.StableNodeID(stableIDStr) + + var ft *apitype.FileTarget + for _, x := range fts { + if x.Node.StableID == stableID { + ft = x + break + } + } + if ft == nil { + http.Error(w, "node not found", 404) + return + } + dstURL, err := url.Parse(ft.PeerAPIURL) + if err != nil { + http.Error(w, "bogus peer URL", 500) + return + } + outReq, err := http.NewRequestWithContext(r.Context(), "PUT", "http://peer/v0/put/"+filenameEscaped, r.Body) + if err != nil { + http.Error(w, "bogus outreq", 500) + return + } + outReq.ContentLength = r.ContentLength + + rp := httputil.NewSingleHostReverseProxy(dstURL) + rp.Transport = h.b.Dialer().PeerAPITransport() + rp.ServeHTTP(w, outReq) +} diff --git a/taildrop/sender_test.go b/taildrop/sender_test.go new file mode 100644 index 000000000..e69de29bb