diff --git a/build_dist.sh b/build_dist.sh index 5b1ca75b2..f11d4aae2 100755 --- a/build_dist.sh +++ b/build_dist.sh @@ -41,7 +41,7 @@ while [ "$#" -gt 1 ]; do fi shift ldflags="$ldflags -w -s" - tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion,ts_omit_ssh,ts_omit_wakeonlan,ts_omit_capture,ts_omit_relayserver" + tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion,ts_omit_ssh,ts_omit_wakeonlan,ts_omit_capture,ts_omit_relayserver,ts_omit_taildrop" ;; --box) if [ ! -z "${TAGS:-}" ]; then diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index cfdb08c20..37a1be6e3 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -811,6 +811,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ tailscale.com/feature/capture from tailscale.com/feature/condregister tailscale.com/feature/condregister from tailscale.com/tsnet tailscale.com/feature/relayserver from tailscale.com/feature/condregister + tailscale.com/feature/taildrop from tailscale.com/feature/condregister L tailscale.com/feature/tap from tailscale.com/feature/condregister tailscale.com/feature/wakeonlan from tailscale.com/feature/condregister tailscale.com/health from tailscale.com/control/controlclient+ @@ -944,7 +945,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal tailscale.com/util/groupmember from tailscale.com/client/web+ 💣 tailscale.com/util/hashx from tailscale.com/util/deephash - tailscale.com/util/httphdr from tailscale.com/ipn/ipnlocal+ + tailscale.com/util/httphdr from tailscale.com/feature/taildrop tailscale.com/util/httpm from tailscale.com/client/tailscale+ tailscale.com/util/lineiter from tailscale.com/hostinfo+ L tailscale.com/util/linuxfw from tailscale.com/net/netns+ @@ -956,7 +957,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal tailscale.com/util/osuser from tailscale.com/ipn/ipnlocal - tailscale.com/util/progresstracking from tailscale.com/ipn/localapi + tailscale.com/util/progresstracking from tailscale.com/feature/taildrop tailscale.com/util/race from tailscale.com/net/dns/resolver tailscale.com/util/racebuild from tailscale.com/logpolicy tailscale.com/util/rands from tailscale.com/ipn/ipnlocal+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 4e6502b72..31881822f 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -269,6 +269,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/feature/capture from tailscale.com/feature/condregister tailscale.com/feature/condregister from tailscale.com/cmd/tailscaled tailscale.com/feature/relayserver from tailscale.com/feature/condregister + tailscale.com/feature/taildrop from tailscale.com/feature/condregister L tailscale.com/feature/tap from tailscale.com/feature/condregister tailscale.com/feature/wakeonlan from tailscale.com/feature/condregister tailscale.com/health from tailscale.com/control/controlclient+ @@ -396,7 +397,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal tailscale.com/util/groupmember from tailscale.com/client/web+ 💣 tailscale.com/util/hashx from tailscale.com/util/deephash - tailscale.com/util/httphdr from tailscale.com/ipn/ipnlocal+ + tailscale.com/util/httphdr from tailscale.com/feature/taildrop tailscale.com/util/httpm from tailscale.com/client/tailscale+ tailscale.com/util/lineiter from tailscale.com/hostinfo+ L tailscale.com/util/linuxfw from tailscale.com/net/netns+ @@ -408,7 +409,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag tailscale.com/util/osshare from tailscale.com/cmd/tailscaled+ tailscale.com/util/osuser from tailscale.com/ipn/ipnlocal+ - tailscale.com/util/progresstracking from tailscale.com/ipn/localapi + tailscale.com/util/progresstracking from tailscale.com/feature/taildrop tailscale.com/util/race from tailscale.com/net/dns/resolver tailscale.com/util/racebuild from tailscale.com/logpolicy tailscale.com/util/rands from tailscale.com/ipn/ipnlocal+ diff --git a/cmd/tailscaled/taildrop.go b/cmd/tailscaled/taildrop.go index 39fe54373..3eda9bebf 100644 --- a/cmd/tailscaled/taildrop.go +++ b/cmd/tailscaled/taildrop.go @@ -1,7 +1,7 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -//go:build go1.19 +//go:build !ts_omit_taildrop package main diff --git a/cmd/tailscaled/taildrop_omit.go b/cmd/tailscaled/taildrop_omit.go new file mode 100644 index 000000000..3b7669391 --- /dev/null +++ b/cmd/tailscaled/taildrop_omit.go @@ -0,0 +1,15 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build ts_omit_taildrop + +package main + +import ( + "tailscale.com/ipn/ipnlocal" + "tailscale.com/types/logger" +) + +func configureTaildrop(logf logger.Logf, lb *ipnlocal.LocalBackend) { + // Nothing. +} diff --git a/feature/condregister/maybe_taildrop.go b/feature/condregister/maybe_taildrop.go new file mode 100644 index 000000000..5fd7b5f8c --- /dev/null +++ b/feature/condregister/maybe_taildrop.go @@ -0,0 +1,8 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_taildrop + +package condregister + +import _ "tailscale.com/feature/taildrop" diff --git a/feature/taildrop/doc.go b/feature/taildrop/doc.go new file mode 100644 index 000000000..8980a2170 --- /dev/null +++ b/feature/taildrop/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package taildrop registers the taildrop (file sending) feature. +package taildrop diff --git a/feature/taildrop/ext.go b/feature/taildrop/ext.go new file mode 100644 index 000000000..5d22cfb9b --- /dev/null +++ b/feature/taildrop/ext.go @@ -0,0 +1,54 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package taildrop + +import ( + "tailscale.com/ipn/ipnext" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/taildrop" + "tailscale.com/tsd" + "tailscale.com/types/logger" +) + +func init() { + ipnext.RegisterExtension("taildrop", newExtension) +} + +func newExtension(logf logger.Logf, _ *tsd.System) (ipnext.Extension, error) { + return &extension{ + logf: logger.WithPrefix(logf, "taildrop: "), + }, nil +} + +type extension struct { + logf logger.Logf + lb *ipnlocal.LocalBackend + mgr *taildrop.Manager +} + +func (e *extension) Name() string { + return "taildrop" +} + +func (e *extension) Init(h ipnext.Host) error { + type I interface { + Backend() ipnlocal.Backend + } + e.lb = h.(I).Backend().(*ipnlocal.LocalBackend) + + // TODO(bradfitz): move init of taildrop.Manager from ipnlocal/peerapi.go to + // here + e.mgr = nil + + return nil +} + +func (e *extension) Shutdown() error { + if mgr, err := e.lb.TaildropManager(); err == nil { + mgr.Shutdown() + } else { + e.logf("taildrop: failed to shutdown taildrop manager: %v", err) + } + return nil +} diff --git a/feature/taildrop/localapi.go b/feature/taildrop/localapi.go new file mode 100644 index 000000000..ce812514e --- /dev/null +++ b/feature/taildrop/localapi.go @@ -0,0 +1,429 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package taildrop + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "maps" + "mime" + "mime/multipart" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "time" + + "tailscale.com/client/tailscale/apitype" + "tailscale.com/ipn" + "tailscale.com/ipn/localapi" + "tailscale.com/tailcfg" + "tailscale.com/taildrop" + "tailscale.com/util/clientmetric" + "tailscale.com/util/httphdr" + "tailscale.com/util/mak" + "tailscale.com/util/progresstracking" + "tailscale.com/util/rands" +) + +func init() { + localapi.Register("file-put/", serveFilePut) + localapi.Register("files/", serveFiles) + localapi.Register("file-targets", serveFileTargets) +} + +var ( + metricFilePutCalls = clientmetric.NewCounter("localapi_file_put") +) + +// 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. +// +// In addition to single file PUTs, this endpoint accepts multipart file +// POSTS encoded as multipart/form-data.The first part should be an +// application/json file that contains a manifest consisting of a JSON array of +// OutgoingFiles which we can use for tracking progress even before reading the +// file parts. +// +// URL format: +// +// - PUT /localapi/v0/file-put/:stableID/:escaped-filename +// - POST /localapi/v0/file-put/:stableID +func serveFilePut(h *localapi.Handler, 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" && r.Method != "POST" { + http.Error(w, "want PUT to put file", http.StatusBadRequest) + return + } + + lb := h.LocalBackend() + + fts, err := lb.FileTargets() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + upath, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/file-put/") + if !ok { + http.Error(w, "misconfigured", http.StatusInternalServerError) + return + } + var peerIDStr, filenameEscaped string + if r.Method == "PUT" { + ok := false + peerIDStr, filenameEscaped, ok = strings.Cut(upath, "/") + if !ok { + http.Error(w, "bogus URL", http.StatusBadRequest) + return + } + } else { + peerIDStr = upath + } + peerID := tailcfg.StableNodeID(peerIDStr) + + var ft *apitype.FileTarget + for _, x := range fts { + if x.Node.StableID == peerID { + ft = x + break + } + } + if ft == nil { + http.Error(w, "node not found", http.StatusNotFound) + return + } + dstURL, err := url.Parse(ft.PeerAPIURL) + if err != nil { + http.Error(w, "bogus peer URL", http.StatusInternalServerError) + return + } + + // Periodically report progress of outgoing files. + outgoingFiles := make(map[string]*ipn.OutgoingFile) + t := time.NewTicker(1 * time.Second) + progressUpdates := make(chan ipn.OutgoingFile) + defer close(progressUpdates) + + go func() { + defer t.Stop() + defer lb.UpdateOutgoingFiles(outgoingFiles) + for { + select { + case u, ok := <-progressUpdates: + if !ok { + return + } + outgoingFiles[u.ID] = &u + case <-t.C: + lb.UpdateOutgoingFiles(outgoingFiles) + } + } + }() + + switch r.Method { + case "PUT": + file := ipn.OutgoingFile{ + ID: rands.HexString(30), + PeerID: peerID, + Name: filenameEscaped, + DeclaredSize: r.ContentLength, + } + singleFilePut(h, r.Context(), progressUpdates, w, r.Body, dstURL, file) + case "POST": + multiFilePost(h, progressUpdates, w, r, peerID, dstURL) + default: + http.Error(w, "want PUT to put file", http.StatusBadRequest) + return + } +} + +func multiFilePost(h *localapi.Handler, progressUpdates chan (ipn.OutgoingFile), w http.ResponseWriter, r *http.Request, peerID tailcfg.StableNodeID, dstURL *url.URL) { + _, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + http.Error(w, fmt.Sprintf("invalid Content-Type for multipart POST: %s", err), http.StatusBadRequest) + return + } + + ww := &multiFilePostResponseWriter{} + defer func() { + if err := ww.Flush(w); err != nil { + h.Logf("error: multiFilePostResponseWriter.Flush(): %s", err) + } + }() + + outgoingFilesByName := make(map[string]ipn.OutgoingFile) + first := true + mr := multipart.NewReader(r.Body, params["boundary"]) + for { + part, err := mr.NextPart() + if err == io.EOF { + // No more parts. + return + } else if err != nil { + http.Error(ww, fmt.Sprintf("failed to decode multipart/form-data: %s", err), http.StatusBadRequest) + return + } + + if first { + first = false + if part.Header.Get("Content-Type") != "application/json" { + http.Error(ww, "first MIME part must be a JSON map of filename -> size", http.StatusBadRequest) + return + } + + var manifest []ipn.OutgoingFile + err := json.NewDecoder(part).Decode(&manifest) + if err != nil { + http.Error(ww, fmt.Sprintf("invalid manifest: %s", err), http.StatusBadRequest) + return + } + + for _, file := range manifest { + outgoingFilesByName[file.Name] = file + progressUpdates <- file + } + + continue + } + + if !singleFilePut(h, r.Context(), progressUpdates, ww, part, dstURL, outgoingFilesByName[part.FileName()]) { + return + } + + if ww.statusCode >= 400 { + // put failed, stop immediately + h.Logf("error: singleFilePut: failed with status %d", ww.statusCode) + return + } + } +} + +// multiFilePostResponseWriter is a buffering http.ResponseWriter that can be +// reused across multiple singleFilePut calls and then flushed to the client +// when all files have been PUT. +type multiFilePostResponseWriter struct { + header http.Header + statusCode int + body *bytes.Buffer +} + +func (ww *multiFilePostResponseWriter) Header() http.Header { + if ww.header == nil { + ww.header = make(http.Header) + } + return ww.header +} + +func (ww *multiFilePostResponseWriter) WriteHeader(statusCode int) { + ww.statusCode = statusCode +} + +func (ww *multiFilePostResponseWriter) Write(p []byte) (int, error) { + if ww.body == nil { + ww.body = bytes.NewBuffer(nil) + } + return ww.body.Write(p) +} + +func (ww *multiFilePostResponseWriter) Flush(w http.ResponseWriter) error { + if ww.header != nil { + maps.Copy(w.Header(), ww.header) + } + if ww.statusCode > 0 { + w.WriteHeader(ww.statusCode) + } + if ww.body != nil { + _, err := io.Copy(w, ww.body) + return err + } + return nil +} + +func singleFilePut( + h *localapi.Handler, + ctx context.Context, + progressUpdates chan (ipn.OutgoingFile), + w http.ResponseWriter, + body io.Reader, + dstURL *url.URL, + outgoingFile ipn.OutgoingFile, +) bool { + outgoingFile.Started = time.Now() + body = progresstracking.NewReader(body, 1*time.Second, func(n int, err error) { + outgoingFile.Sent = int64(n) + progressUpdates <- outgoingFile + }) + + fail := func() { + outgoingFile.Finished = true + outgoingFile.Succeeded = false + progressUpdates <- outgoingFile + } + + // Before we PUT a file we check to see if there are any existing partial file and if so, + // we resume the upload from where we left off by sending the remaining file instead of + // the full file. + var offset int64 + var resumeDuration time.Duration + remainingBody := io.Reader(body) + client := &http.Client{ + Transport: h.LocalBackend().Dialer().PeerAPITransport(), + Timeout: 10 * time.Second, + } + req, err := http.NewRequestWithContext(ctx, "GET", dstURL.String()+"/v0/put/"+outgoingFile.Name, nil) + if err != nil { + http.Error(w, "bogus peer URL", http.StatusInternalServerError) + fail() + return false + } + switch resp, err := client.Do(req); { + case err != nil: + h.Logf("could not fetch remote hashes: %v", err) + case resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotFound: + // noop; implies older peerapi without resume support + case resp.StatusCode != http.StatusOK: + h.Logf("fetch remote hashes status code: %d", resp.StatusCode) + default: + resumeStart := time.Now() + dec := json.NewDecoder(resp.Body) + offset, remainingBody, err = taildrop.ResumeReader(body, func() (out taildrop.BlockChecksum, err error) { + err = dec.Decode(&out) + return out, err + }) + if err != nil { + h.Logf("reader could not be fully resumed: %v", err) + } + resumeDuration = time.Since(resumeStart).Round(time.Millisecond) + } + + outReq, err := http.NewRequestWithContext(ctx, "PUT", "http://peer/v0/put/"+outgoingFile.Name, remainingBody) + if err != nil { + http.Error(w, "bogus outreq", http.StatusInternalServerError) + fail() + return false + } + outReq.ContentLength = outgoingFile.DeclaredSize + if offset > 0 { + h.Logf("resuming put at offset %d after %v", offset, resumeDuration) + rangeHdr, _ := httphdr.FormatRange([]httphdr.Range{{Start: offset, Length: 0}}) + outReq.Header.Set("Range", rangeHdr) + if outReq.ContentLength >= 0 { + outReq.ContentLength -= offset + } + } + + rp := httputil.NewSingleHostReverseProxy(dstURL) + rp.Transport = h.LocalBackend().Dialer().PeerAPITransport() + rp.ServeHTTP(w, outReq) + + outgoingFile.Finished = true + outgoingFile.Succeeded = true + progressUpdates <- outgoingFile + + return true +} + +func serveFiles(h *localapi.Handler, w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "file access denied", http.StatusForbidden) + return + } + lb := h.LocalBackend() + 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", http.StatusBadRequest) + 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 := lb.AwaitWaitingFiles(ctx) + if err != nil && ctx.Err() == nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + 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", http.StatusBadRequest) + return + } + if r.Method == "DELETE" { + if err := lb.DeleteFile(name); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + rc, size, err := lb.OpenFile(name) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + 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 serveFileTargets(h *localapi.Handler, 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", http.StatusBadRequest) + return + } + fts, err := h.LocalBackend().FileTargets() + if err != nil { + localapi.WriteErrorJSON(w, err) + return + } + mak.NonNilSliceForJSON(&fts) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(fts) +} diff --git a/feature/taildrop/peerapi.go b/feature/taildrop/peerapi.go new file mode 100644 index 000000000..f90dca9dc --- /dev/null +++ b/feature/taildrop/peerapi.go @@ -0,0 +1,166 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package taildrop + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "tailscale.com/ipn/ipnlocal" + "tailscale.com/tailcfg" + "tailscale.com/taildrop" + "tailscale.com/tstime" + "tailscale.com/util/clientmetric" + "tailscale.com/util/httphdr" +) + +func init() { + ipnlocal.RegisterPeerAPIHandler("/v0/put/", handlePeerPut) +} + +var ( + metricPutCalls = clientmetric.NewCounter("peerapi_put") +) + +// canPutFile reports whether h can put a file ("Taildrop") to this node. +func canPutFile(h ipnlocal.PeerAPIHandler) bool { + if h.Peer().UnsignedPeerAPIOnly() { + // Unsigned peers can't send files. + return false + } + return h.IsSelfUntagged() || h.PeerCaps().HasCapability(tailcfg.PeerCapabilityFileSharingSend) +} + +func handlePeerPut(h ipnlocal.PeerAPIHandler, w http.ResponseWriter, r *http.Request) { + lb := h.LocalBackend() + handlePeerPutWithBackend(h, lb, w, r) +} + +// localBackend is the subset of ipnlocal.Backend that taildrop +// file put needs. This is pulled out for testability. +type localBackend interface { + TaildropManager() (*taildrop.Manager, error) + HasCapFileSharing() bool + Clock() tstime.Clock +} + +func handlePeerPutWithBackend(h ipnlocal.PeerAPIHandler, lb localBackend, w http.ResponseWriter, r *http.Request) { + if r.Method == "PUT" { + metricPutCalls.Add(1) + } + + taildropMgr, err := lb.TaildropManager() + if err != nil { + h.Logf("taildropManager: %v", err) + http.Error(w, "failed to get taildrop manager", http.StatusInternalServerError) + return + } + + if !canPutFile(h) { + http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusForbidden) + return + } + if !lb.HasCapFileSharing() { + http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusForbidden) + return + } + rawPath := r.URL.EscapedPath() + prefix, ok := strings.CutPrefix(rawPath, "/v0/put/") + if !ok { + http.Error(w, "misconfigured internals", http.StatusForbidden) + return + } + baseName, err := url.PathUnescape(prefix) + if err != nil { + http.Error(w, taildrop.ErrInvalidFileName.Error(), http.StatusBadRequest) + return + } + enc := json.NewEncoder(w) + switch r.Method { + case "GET": + id := taildrop.ClientID(h.Peer().StableID()) + if prefix == "" { + // List all the partial files. + files, err := taildropMgr.PartialFiles(id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := enc.Encode(files); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + h.Logf("json.Encoder.Encode error: %v", err) + return + } + } else { + // Stream all the block hashes for the specified file. + next, close, err := taildropMgr.HashPartialFile(id, baseName) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer close() + for { + switch cs, err := next(); { + case err == io.EOF: + return + case err != nil: + http.Error(w, err.Error(), http.StatusInternalServerError) + h.Logf("HashPartialFile.next error: %v", err) + return + default: + if err := enc.Encode(cs); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + h.Logf("json.Encoder.Encode error: %v", err) + return + } + } + } + } + case "PUT": + t0 := lb.Clock().Now() + id := taildrop.ClientID(h.Peer().StableID()) + + var offset int64 + if rangeHdr := r.Header.Get("Range"); rangeHdr != "" { + ranges, ok := httphdr.ParseRange(rangeHdr) + if !ok || len(ranges) != 1 || ranges[0].Length != 0 { + http.Error(w, "invalid Range header", http.StatusBadRequest) + return + } + offset = ranges[0].Start + } + n, err := taildropMgr.PutFile(taildrop.ClientID(fmt.Sprint(id)), baseName, r.Body, offset, r.ContentLength) + switch err { + case nil: + d := lb.Clock().Since(t0).Round(time.Second / 10) + h.Logf("got put of %s in %v from %v/%v", approxSize(n), d, h.RemoteAddr().Addr(), h.Peer().ComputedName) + io.WriteString(w, "{}\n") + case taildrop.ErrNoTaildrop: + http.Error(w, err.Error(), http.StatusForbidden) + case taildrop.ErrInvalidFileName: + http.Error(w, err.Error(), http.StatusBadRequest) + case taildrop.ErrFileExists: + http.Error(w, err.Error(), http.StatusConflict) + default: + http.Error(w, err.Error(), http.StatusInternalServerError) + } + default: + http.Error(w, "expected method GET or PUT", http.StatusMethodNotAllowed) + } +} + +func approxSize(n int64) string { + if n <= 1<<10 { + return "<=1KB" + } + if n <= 1<<20 { + return "<=1MB" + } + return fmt.Sprintf("~%dMB", n>>20) +} diff --git a/feature/taildrop/peerapi_test.go b/feature/taildrop/peerapi_test.go new file mode 100644 index 000000000..46a61f547 --- /dev/null +++ b/feature/taildrop/peerapi_test.go @@ -0,0 +1,574 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package taildrop + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "math/rand" + "net/http" + "net/http/httptest" + "net/netip" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "tailscale.com/client/tailscale/apitype" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/tailcfg" + "tailscale.com/taildrop" + "tailscale.com/tstest" + "tailscale.com/tstime" + "tailscale.com/types/logger" +) + +// peerAPIHandler serves the PeerAPI for a source specific client. +type peerAPIHandler struct { + remoteAddr netip.AddrPort + isSelf bool // whether peerNode is owned by same user as this node + selfNode tailcfg.NodeView // this node; always non-nil + peerNode tailcfg.NodeView // peerNode is who's making the request +} + +func (h *peerAPIHandler) IsSelfUntagged() bool { + return !h.selfNode.IsTagged() && !h.peerNode.IsTagged() && h.isSelf +} +func (h *peerAPIHandler) Peer() tailcfg.NodeView { return h.peerNode } +func (h *peerAPIHandler) Self() tailcfg.NodeView { return h.selfNode } +func (h *peerAPIHandler) RemoteAddr() netip.AddrPort { return h.remoteAddr } +func (h *peerAPIHandler) LocalBackend() *ipnlocal.LocalBackend { panic("unexpected") } +func (h *peerAPIHandler) Logf(format string, a ...any) { + //h.logf(format, a...) +} + +func (h *peerAPIHandler) PeerCaps() tailcfg.PeerCapMap { + return nil +} + +type fakeLocalBackend struct { + logf logger.Logf + capFileSharing bool + clock tstime.Clock + taildrop *taildrop.Manager +} + +func (lb *fakeLocalBackend) Clock() tstime.Clock { return lb.clock } +func (lb *fakeLocalBackend) HasCapFileSharing() bool { + return lb.capFileSharing +} +func (lb *fakeLocalBackend) TaildropManager() (*taildrop.Manager, error) { + return lb.taildrop, nil +} + +type peerAPITestEnv struct { + taildrop *taildrop.Manager + ph *peerAPIHandler + rr *httptest.ResponseRecorder + logBuf tstest.MemLogger +} + +type check func(*testing.T, *peerAPITestEnv) + +func checks(vv ...check) []check { return vv } + +func httpStatus(wantStatus int) check { + return func(t *testing.T, e *peerAPITestEnv) { + if res := e.rr.Result(); res.StatusCode != wantStatus { + t.Errorf("HTTP response code = %v; want %v", res.Status, wantStatus) + } + } +} + +func bodyContains(sub string) check { + return func(t *testing.T, e *peerAPITestEnv) { + if body := e.rr.Body.String(); !strings.Contains(body, sub) { + t.Errorf("HTTP response body does not contain %q; got: %s", sub, body) + } + } +} + +func fileHasSize(name string, size int) check { + return func(t *testing.T, e *peerAPITestEnv) { + root := e.taildrop.Dir() + if root == "" { + t.Errorf("no rootdir; can't check whether %q has size %v", name, size) + return + } + path := filepath.Join(root, name) + if fi, err := os.Stat(path); err != nil { + t.Errorf("fileHasSize(%q, %v): %v", name, size, err) + } else if fi.Size() != int64(size) { + t.Errorf("file %q has size %v; want %v", name, fi.Size(), size) + } + } +} + +func fileHasContents(name string, want string) check { + return func(t *testing.T, e *peerAPITestEnv) { + root := e.taildrop.Dir() + if root == "" { + t.Errorf("no rootdir; can't check contents of %q", name) + return + } + path := filepath.Join(root, name) + got, err := os.ReadFile(path) + if err != nil { + t.Errorf("fileHasContents: %v", err) + return + } + if string(got) != want { + t.Errorf("file contents = %q; want %q", got, want) + } + } +} + +func hexAll(v string) string { + var sb strings.Builder + for i := range len(v) { + fmt.Fprintf(&sb, "%%%02x", v[i]) + } + return sb.String() +} + +func TestHandlePeerAPI(t *testing.T) { + tests := []struct { + name string + isSelf bool // the peer sending the request is owned by us + capSharing bool // self node has file sharing capability + debugCap bool // self node has debug capability + omitRoot bool // don't configure + reqs []*http.Request + checks []check + }{ + { + name: "reject_non_owner_put", + isSelf: false, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)}, + checks: checks( + httpStatus(http.StatusForbidden), + bodyContains("Taildrop disabled"), + ), + }, + { + name: "owner_without_cap", + isSelf: true, + capSharing: false, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)}, + checks: checks( + httpStatus(http.StatusForbidden), + bodyContains("Taildrop disabled"), + ), + }, + { + name: "owner_with_cap_no_rootdir", + omitRoot: true, + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)}, + checks: checks( + httpStatus(http.StatusForbidden), + bodyContains("Taildrop disabled; no storage directory"), + ), + }, + { + name: "bad_method", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("POST", "/v0/put/foo", nil)}, + checks: checks( + httpStatus(405), + bodyContains("expected method GET or PUT"), + ), + }, + { + name: "put_zero_length", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)}, + checks: checks( + httpStatus(200), + bodyContains("{}"), + fileHasSize("foo", 0), + fileHasContents("foo", ""), + ), + }, + { + name: "put_non_zero_length_content_length", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents"))}, + checks: checks( + httpStatus(200), + bodyContains("{}"), + fileHasSize("foo", len("contents")), + fileHasContents("foo", "contents"), + ), + }, + { + name: "put_non_zero_length_chunked", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", struct{ io.Reader }{strings.NewReader("contents")})}, + checks: checks( + httpStatus(200), + bodyContains("{}"), + fileHasSize("foo", len("contents")), + fileHasContents("foo", "contents"), + ), + }, + { + name: "bad_filename_partial", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo.partial", nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "bad_filename_deleted", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo.deleted", nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "bad_filename_dot", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/.", nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "bad_filename_empty", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/", nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "bad_filename_slash", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo/bar", nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "bad_filename_encoded_dot", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("."), nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "bad_filename_encoded_slash", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("/"), nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "bad_filename_encoded_backslash", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("\\"), nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "bad_filename_encoded_dotdot", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll(".."), nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "bad_filename_encoded_dotdot_out", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("foo/../../../../../etc/passwd"), nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "put_spaces_and_caps", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("Foo Bar.dat"), strings.NewReader("baz"))}, + checks: checks( + httpStatus(200), + bodyContains("{}"), + fileHasContents("Foo Bar.dat", "baz"), + ), + }, + { + name: "put_unicode", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("Томас и его друзья.mp3"), strings.NewReader("главный озорник"))}, + checks: checks( + httpStatus(200), + bodyContains("{}"), + fileHasContents("Томас и его друзья.mp3", "главный озорник"), + ), + }, + { + name: "put_invalid_utf8", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+(hexAll("😜")[:3]), nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "put_invalid_null", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/%00", nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "put_invalid_non_printable", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/%01", nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "put_invalid_colon", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("nul:"), nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "put_invalid_surrounding_whitespace", + isSelf: true, + capSharing: true, + reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll(" foo "), nil)}, + checks: checks( + httpStatus(400), + bodyContains("invalid filename"), + ), + }, + { + name: "duplicate_zero_length", + isSelf: true, + capSharing: true, + reqs: []*http.Request{ + httptest.NewRequest("PUT", "/v0/put/foo", nil), + httptest.NewRequest("PUT", "/v0/put/foo", nil), + }, + checks: checks( + httpStatus(200), + func(t *testing.T, env *peerAPITestEnv) { + got, err := env.taildrop.WaitingFiles() + if err != nil { + t.Fatalf("WaitingFiles error: %v", err) + } + want := []apitype.WaitingFile{{Name: "foo", Size: 0}} + if diff := cmp.Diff(got, want); diff != "" { + t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff) + } + }, + ), + }, + { + name: "duplicate_non_zero_length_content_length", + isSelf: true, + capSharing: true, + reqs: []*http.Request{ + httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents")), + httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents")), + }, + checks: checks( + httpStatus(200), + func(t *testing.T, env *peerAPITestEnv) { + got, err := env.taildrop.WaitingFiles() + if err != nil { + t.Fatalf("WaitingFiles error: %v", err) + } + want := []apitype.WaitingFile{{Name: "foo", Size: 8}} + if diff := cmp.Diff(got, want); diff != "" { + t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff) + } + }, + ), + }, + { + name: "duplicate_different_files", + isSelf: true, + capSharing: true, + reqs: []*http.Request{ + httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("fizz")), + httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("buzz")), + }, + checks: checks( + httpStatus(200), + func(t *testing.T, env *peerAPITestEnv) { + got, err := env.taildrop.WaitingFiles() + if err != nil { + t.Fatalf("WaitingFiles error: %v", err) + } + want := []apitype.WaitingFile{{Name: "foo", Size: 4}, {Name: "foo (1)", Size: 4}} + if diff := cmp.Diff(got, want); diff != "" { + t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff) + } + }, + ), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selfNode := &tailcfg.Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.100.100.101/32"), + }, + } + if tt.debugCap { + selfNode.CapMap = tailcfg.NodeCapMap{tailcfg.CapabilityDebug: nil} + } + var rootDir string + var e peerAPITestEnv + if !tt.omitRoot { + rootDir = t.TempDir() + e.taildrop = taildrop.ManagerOptions{ + Logf: e.logBuf.Logf, + Dir: rootDir, + }.New() + } + + lb := &fakeLocalBackend{ + logf: e.logBuf.Logf, + capFileSharing: tt.capSharing, + clock: &tstest.Clock{}, + taildrop: e.taildrop, + } + e.ph = &peerAPIHandler{ + isSelf: tt.isSelf, + selfNode: selfNode.View(), + peerNode: (&tailcfg.Node{ + ComputedName: "some-peer-name", + }).View(), + } + for _, req := range tt.reqs { + e.rr = httptest.NewRecorder() + if req.Host == "example.com" { + req.Host = "100.100.100.101:12345" + } + handlePeerPutWithBackend(e.ph, lb, e.rr, req) + } + for _, f := range tt.checks { + f(t, &e) + } + if t.Failed() && rootDir != "" { + t.Logf("Contents of %s:", rootDir) + des, _ := fs.ReadDir(os.DirFS(rootDir), ".") + for _, de := range des { + fi, err := de.Info() + if err != nil { + t.Log(err) + } else { + t.Logf(" %v %5d %s", fi.Mode(), fi.Size(), de.Name()) + } + } + } + }) + } +} + +// Windows likes to hold on to file descriptors for some indeterminate +// amount of time after you close them and not let you delete them for +// a bit. So test that we work around that sufficiently. +func TestFileDeleteRace(t *testing.T) { + dir := t.TempDir() + taildropMgr := taildrop.ManagerOptions{ + Logf: t.Logf, + Dir: dir, + }.New() + + ph := &peerAPIHandler{ + isSelf: true, + peerNode: (&tailcfg.Node{ + ComputedName: "some-peer-name", + }).View(), + selfNode: (&tailcfg.Node{ + Addresses: []netip.Prefix{netip.MustParsePrefix("100.100.100.101/32")}, + }).View(), + } + fakeLB := &fakeLocalBackend{ + logf: t.Logf, + capFileSharing: true, + clock: &tstest.Clock{}, + taildrop: taildropMgr, + } + buf := make([]byte, 2<<20) + for range 30 { + rr := httptest.NewRecorder() + handlePeerPutWithBackend(ph, fakeLB, rr, httptest.NewRequest("PUT", "http://100.100.100.101:123/v0/put/foo.txt", bytes.NewReader(buf[:rand.Intn(len(buf))]))) + if res := rr.Result(); res.StatusCode != 200 { + t.Fatal(res.Status) + } + wfs, err := taildropMgr.WaitingFiles() + if err != nil { + t.Fatal(err) + } + if len(wfs) != 1 { + t.Fatalf("waiting files = %d; want 1", len(wfs)) + } + + if err := taildropMgr.DeleteFile("foo.txt"); err != nil { + t.Fatal(err) + } + wfs, err = taildropMgr.WaitingFiles() + if err != nil { + t.Fatal(err) + } + if len(wfs) != 0 { + t.Fatalf("waiting files = %d; want 0", len(wfs)) + } + } +} diff --git a/ipn/ipnlocal/extension_host.go b/ipn/ipnlocal/extension_host.go index 2a8a6a085..79f741e55 100644 --- a/ipn/ipnlocal/extension_host.go +++ b/ipn/ipnlocal/extension_host.go @@ -67,6 +67,7 @@ import ( // and to further reduce the risk of accessing unexported methods or fields of [LocalBackend], the host interacts // with it via the [Backend] interface. type ExtensionHost struct { + b Backend logf logger.Logf // prefixed with "ipnext:" // allExtensions holds the extensions in the order they were registered, @@ -139,6 +140,7 @@ type Backend interface { // Overriding extensions is primarily used for testing. func NewExtensionHost(logf logger.Logf, sys *tsd.System, b Backend, overrideExts ...*ipnext.Definition) (_ *ExtensionHost, err error) { host := &ExtensionHost{ + b: b, logf: logger.WithPrefix(logf, "ipnext: "), workQueue: &execqueue.ExecQueue{}, // The host starts with an empty profile and default prefs. @@ -332,6 +334,14 @@ func (h *ExtensionHost) SwitchToBestProfileAsync(reason string) { }) } +// Backend returns the [Backend] used by the extension host. +func (h *ExtensionHost) Backend() Backend { + if h == nil { + return nil + } + return h.b +} + // RegisterProfileStateChangeCallback implements [ipnext.ProfileServices]. func (h *ExtensionHost) RegisterProfileStateChangeCallback(cb ipnext.ProfileStateChangeCallback) (unregister func()) { if h == nil { diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 45daefda8..ef5ec267f 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -30,7 +30,6 @@ import ( "reflect" "runtime" "slices" - "sort" "strconv" "strings" "sync" @@ -81,7 +80,6 @@ import ( "tailscale.com/posture" "tailscale.com/syncs" "tailscale.com/tailcfg" - "tailscale.com/taildrop" "tailscale.com/tka" "tailscale.com/tsd" "tailscale.com/tstime" @@ -590,6 +588,8 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo return b, nil } +func (b *LocalBackend) Clock() tstime.Clock { return b.clock } + // FindExtensionByName returns an active extension with the given name, // or nil if no such extension exists. func (b *LocalBackend) FindExtensionByName(name string) any { @@ -1075,9 +1075,6 @@ func (b *LocalBackend) Shutdown() { defer cancel() b.sockstatLogger.Shutdown(ctx) } - if b.peerAPIServer != nil { - b.peerAPIServer.taildrop.Shutdown() - } b.stopOfflineAutoUpdate() b.unregisterNetMon() @@ -1291,7 +1288,9 @@ func (b *LocalBackend) populatePeerStatusLocked(sb *ipnstate.StatusBuilder) { SSH_HostKeys: p.Hostinfo().SSH_HostKeys().AsSlice(), Location: p.Hostinfo().Location().AsStruct(), Capabilities: p.Capabilities().AsSlice(), - TaildropTarget: b.taildropTargetStatus(p), + } + if f := hookSetPeerStatusTaildropTargetLocked; f != nil { + f(b, ps, p) } if cm := p.CapMap(); cm.Len() > 0 { ps.CapMap = make(tailcfg.NodeCapMap, cm.Len()) @@ -3248,6 +3247,17 @@ func (b *LocalBackend) sendTo(n ipn.Notify, recipient notificationTarget) { b.sendToLocked(n, recipient) } +var ( + // hookSetNotifyFilesWaitingLocked, if non-nil, is called in sendToLocked to + // populate ipn.Notify.FilesWaiting when taildrop is linked in to the binary + // and enabled on a LocalBackend. + hookSetNotifyFilesWaitingLocked func(*LocalBackend, *ipn.Notify) + + // hookSetPeerStatusTaildropTargetLocked, if non-nil, is called to populate PeerStatus + // if taildrop is linked in to the binary and enabled on the LocalBackend. + hookSetPeerStatusTaildropTargetLocked func(*LocalBackend, *ipnstate.PeerStatus, tailcfg.NodeView) +) + // sendToLocked is like [LocalBackend.sendTo], but assumes b.mu is already held. func (b *LocalBackend) sendToLocked(n ipn.Notify, recipient notificationTarget) { if n.Prefs != nil { @@ -3257,9 +3267,8 @@ func (b *LocalBackend) sendToLocked(n ipn.Notify, recipient notificationTarget) n.Version = version.Long() } - apiSrv := b.peerAPIServer - if mayDeref(apiSrv).taildrop.HasFilesWaiting() { - n.FilesWaiting = &empty.Message{} + if f := hookSetNotifyFilesWaitingLocked; f != nil { + f(b, &n) } for _, sess := range b.notifyWatchers { @@ -3273,32 +3282,6 @@ func (b *LocalBackend) sendToLocked(n ipn.Notify, recipient notificationTarget) } } -func (b *LocalBackend) sendFileNotify() { - var n ipn.Notify - - b.mu.Lock() - for _, wakeWaiter := range b.fileWaiters { - wakeWaiter() - } - apiSrv := b.peerAPIServer - if apiSrv == nil { - b.mu.Unlock() - return - } - - // Make sure we always set n.IncomingFiles non-nil so it gets encoded - // in JSON to clients. They distinguish between empty and non-nil - // to know whether a Notify should be able about files. - n.IncomingFiles = apiSrv.taildrop.IncomingFiles() - b.mu.Unlock() - - sort.Slice(n.IncomingFiles, func(i, j int) bool { - return n.IncomingFiles[i].Started.Before(n.IncomingFiles[j].Started) - }) - - b.send(n) -} - // setAuthURL sets the authURL and triggers [LocalBackend.popBrowserAuthNow] if the URL has changed. // This method is called when a new authURL is received from the control plane, meaning that either a user // has started a new interactive login (e.g., by running `tailscale login` or clicking Login in the GUI), @@ -5289,21 +5272,9 @@ func (b *LocalBackend) initPeerAPIListener() { return } - fileRoot := b.fileRootLocked(selfNode.User()) - if fileRoot == "" { - b.logf("peerapi starting without Taildrop directory configured") - } - ps := &peerAPIServer{ - b: b, - taildrop: taildrop.ManagerOptions{ - Logf: b.logf, - Clock: tstime.DefaultClock{Clock: b.clock}, - State: b.store, - Dir: fileRoot, - DirectFileMode: b.directFileRoot != "", - SendFileNotify: b.sendFileNotify, - }.New(), + b: b, + taildrop: b.newTaildropManager(b.fileRootLocked(selfNode.User())), } if dm, ok := b.sys.DNSManager.GetOK(); ok { ps.resolver = dm.Resolver() @@ -6598,172 +6569,6 @@ func (b *LocalBackend) TestOnlyPublicKeys() (machineKey key.MachinePublic, nodeK return mk, nk } -func (b *LocalBackend) removeFileWaiter(handle set.Handle) { - b.mu.Lock() - defer b.mu.Unlock() - delete(b.fileWaiters, handle) -} - -func (b *LocalBackend) addFileWaiter(wakeWaiter context.CancelFunc) set.Handle { - b.mu.Lock() - defer b.mu.Unlock() - return b.fileWaiters.Add(wakeWaiter) -} - -func (b *LocalBackend) WaitingFiles() ([]apitype.WaitingFile, error) { - b.mu.Lock() - apiSrv := b.peerAPIServer - b.mu.Unlock() - return mayDeref(apiSrv).taildrop.WaitingFiles() -} - -// AwaitWaitingFiles is like WaitingFiles but blocks while ctx is not done, -// waiting for any files to be available. -// -// On return, exactly one of the results will be non-empty or non-nil, -// respectively. -func (b *LocalBackend) AwaitWaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) { - if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 { - return ff, err - } - - for { - gotFile, gotFileCancel := context.WithCancel(context.Background()) - defer gotFileCancel() - - handle := b.addFileWaiter(gotFileCancel) - defer b.removeFileWaiter(handle) - - // Now that we've registered ourselves, check again, in case - // of race. Otherwise there's a small window where we could - // miss a file arrival and wait forever. - if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 { - return ff, err - } - - select { - case <-gotFile.Done(): - if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 { - return ff, err - } - case <-ctx.Done(): - return nil, ctx.Err() - } - } -} - -func (b *LocalBackend) DeleteFile(name string) error { - b.mu.Lock() - apiSrv := b.peerAPIServer - b.mu.Unlock() - return mayDeref(apiSrv).taildrop.DeleteFile(name) -} - -func (b *LocalBackend) OpenFile(name string) (rc io.ReadCloser, size int64, err error) { - b.mu.Lock() - apiSrv := b.peerAPIServer - b.mu.Unlock() - return mayDeref(apiSrv).taildrop.OpenFile(name) -} - -// hasCapFileSharing reports whether the current node has the file -// sharing capability enabled. -func (b *LocalBackend) hasCapFileSharing() bool { - b.mu.Lock() - defer b.mu.Unlock() - return b.capFileSharing -} - -// FileTargets lists nodes that the current node can send files to. -func (b *LocalBackend) FileTargets() ([]*apitype.FileTarget, error) { - var ret []*apitype.FileTarget - - b.mu.Lock() - defer b.mu.Unlock() - nm := b.netMap - if b.state != ipn.Running || nm == nil { - return nil, errors.New("not connected to the tailnet") - } - if !b.capFileSharing { - return nil, errors.New("file sharing not enabled by Tailscale admin") - } - for _, p := range b.peers { - if !b.peerIsTaildropTargetLocked(p) { - continue - } - if p.Hostinfo().OS() == "tvOS" { - continue - } - peerAPI := peerAPIBase(b.netMap, p) - if peerAPI == "" { - continue - } - ret = append(ret, &apitype.FileTarget{ - Node: p.AsStruct(), - PeerAPIURL: peerAPI, - }) - } - slices.SortFunc(ret, func(a, b *apitype.FileTarget) int { - return cmp.Compare(a.Node.Name, b.Node.Name) - }) - return ret, nil -} - -func (b *LocalBackend) taildropTargetStatus(p tailcfg.NodeView) ipnstate.TaildropTargetStatus { - if b.state != ipn.Running { - return ipnstate.TaildropTargetIpnStateNotRunning - } - if b.netMap == nil { - return ipnstate.TaildropTargetNoNetmapAvailable - } - if !b.capFileSharing { - return ipnstate.TaildropTargetMissingCap - } - - if !p.Online().Get() { - return ipnstate.TaildropTargetOffline - } - - if !p.Valid() { - return ipnstate.TaildropTargetNoPeerInfo - } - if b.netMap.User() != p.User() { - // Different user must have the explicit file sharing target capability - if p.Addresses().Len() == 0 || - !b.peerHasCapLocked(p.Addresses().At(0).Addr(), tailcfg.PeerCapabilityFileSharingTarget) { - return ipnstate.TaildropTargetOwnedByOtherUser - } - } - - if p.Hostinfo().OS() == "tvOS" { - return ipnstate.TaildropTargetUnsupportedOS - } - if peerAPIBase(b.netMap, p) == "" { - return ipnstate.TaildropTargetNoPeerAPI - } - return ipnstate.TaildropTargetAvailable -} - -// peerIsTaildropTargetLocked reports whether p is a valid Taildrop file -// recipient from this node according to its ownership and the capabilities in -// the netmap. -// -// b.mu must be locked. -func (b *LocalBackend) peerIsTaildropTargetLocked(p tailcfg.NodeView) bool { - if b.netMap == nil || !p.Valid() { - return false - } - if b.netMap.User() == p.User() { - return true - } - if p.Addresses().Len() > 0 && - b.peerHasCapLocked(p.Addresses().At(0).Addr(), tailcfg.PeerCapabilityFileSharingTarget) { - // Explicitly noted in the netmap ACL caps as a target. - return true - } - return false -} - func (b *LocalBackend) peerHasCapLocked(addr netip.Addr, wantCap tailcfg.PeerCapability) bool { return b.peerCapsLocked(addr).HasCapability(wantCap) } @@ -7834,14 +7639,6 @@ func allowedAutoRoute(ipp netip.Prefix) bool { return true } -// 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 -} - var ErrNoPreferredDERP = errors.New("no preferred DERP, try again later") // suggestExitNodeLocked computes a suggestion based on the current netmap and last netcheck report. If diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 3b384fd96..3b9e08638 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -575,54 +575,6 @@ func TestSetUseExitNodeEnabled(t *testing.T) { } } -func TestFileTargets(t *testing.T) { - b := new(LocalBackend) - _, err := b.FileTargets() - if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want { - t.Errorf("before connect: got %q; want %q", got, want) - } - - b.netMap = new(netmap.NetworkMap) - _, err = b.FileTargets() - if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want { - t.Errorf("non-running netmap: got %q; want %q", got, want) - } - - b.state = ipn.Running - _, err = b.FileTargets() - if got, want := fmt.Sprint(err), "file sharing not enabled by Tailscale admin"; got != want { - t.Errorf("without cap: got %q; want %q", got, want) - } - - b.capFileSharing = true - got, err := b.FileTargets() - if err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Fatalf("unexpected %d peers", len(got)) - } - - var peerMap map[tailcfg.NodeID]tailcfg.NodeView - mak.NonNil(&peerMap) - var nodeID tailcfg.NodeID - nodeID = 1234 - peer := &tailcfg.Node{ - ID: 1234, - Hostinfo: (&tailcfg.Hostinfo{OS: "tvOS"}).View(), - } - peerMap[nodeID] = peer.View() - b.peers = peerMap - got, err = b.FileTargets() - if err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Fatalf("unexpected %d peers", len(got)) - } - // (other cases handled by TestPeerAPIBase above) -} - func TestInternalAndExternalInterfaces(t *testing.T) { type interfacePrefix struct { i netmon.Interface diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index 888b876d6..87437daf8 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/ipn/ipnlocal/peerapi.go @@ -15,7 +15,6 @@ import ( "net" "net/http" "net/netip" - "net/url" "os" "path/filepath" "runtime" @@ -37,10 +36,8 @@ import ( "tailscale.com/net/netutil" "tailscale.com/net/sockstats" "tailscale.com/tailcfg" - "tailscale.com/taildrop" "tailscale.com/types/views" "tailscale.com/util/clientmetric" - "tailscale.com/util/httphdr" "tailscale.com/util/httpm" "tailscale.com/wgengine/filter" ) @@ -64,7 +61,7 @@ type peerAPIServer struct { b *LocalBackend resolver peerDNSQueryHandler - taildrop *taildrop.Manager + taildrop *taildrop_Manager } func (s *peerAPIServer) listen(ip netip.Addr, ifState *netmon.State) (ln net.Listener, err error) { @@ -232,6 +229,8 @@ type PeerAPIHandler interface { Self() tailcfg.NodeView LocalBackend() *LocalBackend IsSelfUntagged() bool // whether the peer is untagged and the same as this user + RemoteAddr() netip.AddrPort + Logf(format string, a ...any) } func (h *peerAPIHandler) IsSelfUntagged() bool { @@ -239,7 +238,11 @@ func (h *peerAPIHandler) IsSelfUntagged() bool { } func (h *peerAPIHandler) Peer() tailcfg.NodeView { return h.peerNode } func (h *peerAPIHandler) Self() tailcfg.NodeView { return h.selfNode } +func (h *peerAPIHandler) RemoteAddr() netip.AddrPort { return h.remoteAddr } func (h *peerAPIHandler) LocalBackend() *LocalBackend { return h.ps.b } +func (h *peerAPIHandler) Logf(format string, a ...any) { + h.logf(format, a...) +} func (h *peerAPIHandler) logf(format string, a ...any) { h.ps.b.logf("peerapi: "+format, a...) @@ -327,9 +330,18 @@ func RegisterPeerAPIHandler(path string, f func(PeerAPIHandler, http.ResponseWri panic(fmt.Sprintf("duplicate PeerAPI handler %q", path)) } peerAPIHandlers[path] = f + if strings.HasSuffix(path, "/") { + peerAPIHandlerPrefixes[path] = f + } } -var peerAPIHandlers = map[string]func(PeerAPIHandler, http.ResponseWriter, *http.Request){} // by URL.Path +var ( + peerAPIHandlers = map[string]func(PeerAPIHandler, http.ResponseWriter, *http.Request){} // by URL.Path + + // peerAPIHandlerPrefixes are the subset of peerAPIHandlers where + // the map key ends with a slash, indicating a prefix match. + peerAPIHandlerPrefixes = map[string]func(PeerAPIHandler, http.ResponseWriter, *http.Request){} +) func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if err := h.validatePeerAPIRequest(r); err != nil { @@ -343,12 +355,11 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Content-Type-Options", "nosniff") } - if strings.HasPrefix(r.URL.Path, "/v0/put/") { - if r.Method == "PUT" { - metricPutCalls.Add(1) + for pfx, ph := range peerAPIHandlerPrefixes { + if strings.HasPrefix(r.URL.Path, pfx) { + ph(h, w, r) + return } - h.handlePeerPut(w, r) - return } if strings.HasPrefix(r.URL.Path, "/dns-query") { metricDNSCalls.Add(1) @@ -393,6 +404,10 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ph(h, w, r) return } + if r.URL.Path != "/" { + http.Error(w, "unsupported peerapi path", http.StatusNotFound) + return + } who := h.peerUser.DisplayName fmt.Fprintf(w, ` @@ -630,15 +645,6 @@ func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Req fmt.Fprintln(w, "") } -// 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 { @@ -668,110 +674,6 @@ func (h *peerAPIHandler) PeerCaps() tailcfg.PeerCapMap { return h.ps.b.PeerCaps(h.remoteAddr.Addr()) } -func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) { - if !h.canPutFile() { - http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusForbidden) - return - } - if !h.ps.b.hasCapFileSharing() { - http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusForbidden) - return - } - rawPath := r.URL.EscapedPath() - prefix, ok := strings.CutPrefix(rawPath, "/v0/put/") - if !ok { - http.Error(w, "misconfigured internals", http.StatusForbidden) - return - } - baseName, err := url.PathUnescape(prefix) - if err != nil { - http.Error(w, taildrop.ErrInvalidFileName.Error(), http.StatusBadRequest) - return - } - enc := json.NewEncoder(w) - switch r.Method { - case "GET": - id := taildrop.ClientID(h.peerNode.StableID()) - if prefix == "" { - // List all the partial files. - files, err := h.ps.taildrop.PartialFiles(id) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := enc.Encode(files); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - h.logf("json.Encoder.Encode error: %v", err) - return - } - } else { - // Stream all the block hashes for the specified file. - next, close, err := h.ps.taildrop.HashPartialFile(id, baseName) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer close() - for { - switch cs, err := next(); { - case err == io.EOF: - return - case err != nil: - http.Error(w, err.Error(), http.StatusInternalServerError) - h.logf("HashPartialFile.next error: %v", err) - return - default: - if err := enc.Encode(cs); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - h.logf("json.Encoder.Encode error: %v", err) - return - } - } - } - } - case "PUT": - t0 := h.ps.b.clock.Now() - id := taildrop.ClientID(h.peerNode.StableID()) - - var offset int64 - if rangeHdr := r.Header.Get("Range"); rangeHdr != "" { - ranges, ok := httphdr.ParseRange(rangeHdr) - if !ok || len(ranges) != 1 || ranges[0].Length != 0 { - http.Error(w, "invalid Range header", http.StatusBadRequest) - return - } - offset = ranges[0].Start - } - n, err := h.ps.taildrop.PutFile(taildrop.ClientID(fmt.Sprint(id)), baseName, r.Body, offset, r.ContentLength) - switch err { - case nil: - d := h.ps.b.clock.Since(t0).Round(time.Second / 10) - h.logf("got put of %s in %v from %v/%v", approxSize(n), d, h.remoteAddr.Addr(), h.peerNode.ComputedName) - io.WriteString(w, "{}\n") - case taildrop.ErrNoTaildrop: - http.Error(w, err.Error(), http.StatusForbidden) - case taildrop.ErrInvalidFileName: - http.Error(w, err.Error(), http.StatusBadRequest) - case taildrop.ErrFileExists: - http.Error(w, err.Error(), http.StatusConflict) - default: - http.Error(w, err.Error(), http.StatusInternalServerError) - } - default: - http.Error(w, "expected method GET or PUT", http.StatusMethodNotAllowed) - } -} - -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) { if !h.canDebug() { http.Error(w, "denied; no debug access", http.StatusForbidden) @@ -1244,7 +1146,6 @@ var ( metricInvalidRequests = clientmetric.NewCounter("peerapi_invalid_requests") // Non-debug PeerAPI endpoints. - metricPutCalls = clientmetric.NewCounter("peerapi_put") metricDNSCalls = clientmetric.NewCounter("peerapi_dns") metricIngressCalls = clientmetric.NewCounter("peerapi_ingress") ) diff --git a/ipn/ipnlocal/peerapi_test.go b/ipn/ipnlocal/peerapi_test.go index 7a3f05a9c..77c442060 100644 --- a/ipn/ipnlocal/peerapi_test.go +++ b/ipn/ipnlocal/peerapi_test.go @@ -4,33 +4,23 @@ package ipnlocal import ( - "bytes" "context" "encoding/json" - "fmt" - "io" - "io/fs" - "math/rand" "net/http" "net/http/httptest" "net/netip" - "os" - "path/filepath" "slices" "strings" "testing" - "github.com/google/go-cmp/cmp" "go4.org/netipx" "golang.org/x/net/dns/dnsmessage" "tailscale.com/appc" "tailscale.com/appc/appctest" - "tailscale.com/client/tailscale/apitype" "tailscale.com/health" "tailscale.com/ipn" "tailscale.com/ipn/store/mem" "tailscale.com/tailcfg" - "tailscale.com/taildrop" "tailscale.com/tstest" "tailscale.com/types/logger" "tailscale.com/types/netmap" @@ -75,56 +65,12 @@ func bodyNotContains(sub string) check { } } -func fileHasSize(name string, size int) check { - return func(t *testing.T, e *peerAPITestEnv) { - root := e.ph.ps.taildrop.Dir() - if root == "" { - t.Errorf("no rootdir; can't check whether %q has size %v", name, size) - return - } - path := filepath.Join(root, name) - if fi, err := os.Stat(path); err != nil { - t.Errorf("fileHasSize(%q, %v): %v", name, size, err) - } else if fi.Size() != int64(size) { - t.Errorf("file %q has size %v; want %v", name, fi.Size(), size) - } - } -} - -func fileHasContents(name string, want string) check { - return func(t *testing.T, e *peerAPITestEnv) { - root := e.ph.ps.taildrop.Dir() - if root == "" { - t.Errorf("no rootdir; can't check contents of %q", name) - return - } - path := filepath.Join(root, name) - got, err := os.ReadFile(path) - if err != nil { - t.Errorf("fileHasContents: %v", err) - return - } - if string(got) != want { - t.Errorf("file contents = %q; want %q", got, want) - } - } -} - -func hexAll(v string) string { - var sb strings.Builder - for i := range len(v) { - fmt.Fprintf(&sb, "%%%02x", v[i]) - } - return sb.String() -} - func TestHandlePeerAPI(t *testing.T) { tests := []struct { name string isSelf bool // the peer sending the request is owned by us capSharing bool // self node has file sharing capability debugCap bool // self node has debug capability - omitRoot bool // don't configure reqs []*http.Request checks []check }{ @@ -174,255 +120,6 @@ func TestHandlePeerAPI(t *testing.T) { bodyContains("ServeHTTP"), ), }, - { - name: "reject_non_owner_put", - isSelf: false, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)}, - checks: checks( - httpStatus(http.StatusForbidden), - bodyContains("Taildrop disabled"), - ), - }, - { - name: "owner_without_cap", - isSelf: true, - capSharing: false, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)}, - checks: checks( - httpStatus(http.StatusForbidden), - bodyContains("Taildrop disabled"), - ), - }, - { - name: "owner_with_cap_no_rootdir", - omitRoot: true, - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)}, - checks: checks( - httpStatus(http.StatusForbidden), - bodyContains("Taildrop disabled; no storage directory"), - ), - }, - { - name: "bad_method", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("POST", "/v0/put/foo", nil)}, - checks: checks( - httpStatus(405), - bodyContains("expected method GET or PUT"), - ), - }, - { - name: "put_zero_length", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)}, - checks: checks( - httpStatus(200), - bodyContains("{}"), - fileHasSize("foo", 0), - fileHasContents("foo", ""), - ), - }, - { - name: "put_non_zero_length_content_length", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents"))}, - checks: checks( - httpStatus(200), - bodyContains("{}"), - fileHasSize("foo", len("contents")), - fileHasContents("foo", "contents"), - ), - }, - { - name: "put_non_zero_length_chunked", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", struct{ io.Reader }{strings.NewReader("contents")})}, - checks: checks( - httpStatus(200), - bodyContains("{}"), - fileHasSize("foo", len("contents")), - fileHasContents("foo", "contents"), - ), - }, - { - name: "bad_filename_partial", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo.partial", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_deleted", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo.deleted", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_dot", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/.", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_empty", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_slash", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo/bar", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_encoded_dot", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("."), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_encoded_slash", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("/"), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_encoded_backslash", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("\\"), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_encoded_dotdot", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll(".."), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_encoded_dotdot_out", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("foo/../../../../../etc/passwd"), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "put_spaces_and_caps", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("Foo Bar.dat"), strings.NewReader("baz"))}, - checks: checks( - httpStatus(200), - bodyContains("{}"), - fileHasContents("Foo Bar.dat", "baz"), - ), - }, - { - name: "put_unicode", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("Томас и его друзья.mp3"), strings.NewReader("главный озорник"))}, - checks: checks( - httpStatus(200), - bodyContains("{}"), - fileHasContents("Томас и его друзья.mp3", "главный озорник"), - ), - }, - { - name: "put_invalid_utf8", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+(hexAll("😜")[:3]), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "put_invalid_null", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/%00", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "put_invalid_non_printable", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/%01", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "put_invalid_colon", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("nul:"), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "put_invalid_surrounding_whitespace", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll(" foo "), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, { name: "host-val/bad-ip", isSelf: true, @@ -450,72 +147,6 @@ func TestHandlePeerAPI(t *testing.T) { httpStatus(200), ), }, - { - name: "duplicate_zero_length", - isSelf: true, - capSharing: true, - reqs: []*http.Request{ - httptest.NewRequest("PUT", "/v0/put/foo", nil), - httptest.NewRequest("PUT", "/v0/put/foo", nil), - }, - checks: checks( - httpStatus(200), - func(t *testing.T, env *peerAPITestEnv) { - got, err := env.ph.ps.taildrop.WaitingFiles() - if err != nil { - t.Fatalf("WaitingFiles error: %v", err) - } - want := []apitype.WaitingFile{{Name: "foo", Size: 0}} - if diff := cmp.Diff(got, want); diff != "" { - t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff) - } - }, - ), - }, - { - name: "duplicate_non_zero_length_content_length", - isSelf: true, - capSharing: true, - reqs: []*http.Request{ - httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents")), - httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents")), - }, - checks: checks( - httpStatus(200), - func(t *testing.T, env *peerAPITestEnv) { - got, err := env.ph.ps.taildrop.WaitingFiles() - if err != nil { - t.Fatalf("WaitingFiles error: %v", err) - } - want := []apitype.WaitingFile{{Name: "foo", Size: 8}} - if diff := cmp.Diff(got, want); diff != "" { - t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff) - } - }, - ), - }, - { - name: "duplicate_different_files", - isSelf: true, - capSharing: true, - reqs: []*http.Request{ - httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("fizz")), - httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("buzz")), - }, - checks: checks( - httpStatus(200), - func(t *testing.T, env *peerAPITestEnv) { - got, err := env.ph.ps.taildrop.WaitingFiles() - if err != nil { - t.Fatalf("WaitingFiles error: %v", err) - } - want := []apitype.WaitingFile{{Name: "foo", Size: 4}, {Name: "foo (1)", Size: 4}} - if diff := cmp.Diff(got, want); diff != "" { - t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff) - } - }, - ), - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -544,16 +175,6 @@ func TestHandlePeerAPI(t *testing.T) { b: lb, }, } - var rootDir string - if !tt.omitRoot { - rootDir = t.TempDir() - if e.ph.ps.taildrop == nil { - e.ph.ps.taildrop = taildrop.ManagerOptions{ - Logf: e.logBuf.Logf, - Dir: rootDir, - }.New() - } - } for _, req := range tt.reqs { e.rr = httptest.NewRecorder() if req.Host == "example.com" { @@ -564,76 +185,10 @@ func TestHandlePeerAPI(t *testing.T) { for _, f := range tt.checks { f(t, &e) } - if t.Failed() && rootDir != "" { - t.Logf("Contents of %s:", rootDir) - des, _ := fs.ReadDir(os.DirFS(rootDir), ".") - for _, de := range des { - fi, err := de.Info() - if err != nil { - t.Log(err) - } else { - t.Logf(" %v %5d %s", fi.Mode(), fi.Size(), de.Name()) - } - } - } }) } } -// Windows likes to hold on to file descriptors for some indeterminate -// amount of time after you close them and not let you delete them for -// a bit. So test that we work around that sufficiently. -func TestFileDeleteRace(t *testing.T) { - dir := t.TempDir() - ps := &peerAPIServer{ - b: &LocalBackend{ - logf: t.Logf, - capFileSharing: true, - clock: &tstest.Clock{}, - }, - taildrop: taildrop.ManagerOptions{ - Logf: t.Logf, - Dir: dir, - }.New(), - } - ph := &peerAPIHandler{ - isSelf: true, - peerNode: (&tailcfg.Node{ - ComputedName: "some-peer-name", - }).View(), - selfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{netip.MustParsePrefix("100.100.100.101/32")}, - }).View(), - ps: ps, - } - buf := make([]byte, 2<<20) - for range 30 { - rr := httptest.NewRecorder() - ph.ServeHTTP(rr, httptest.NewRequest("PUT", "http://100.100.100.101:123/v0/put/foo.txt", bytes.NewReader(buf[:rand.Intn(len(buf))]))) - if res := rr.Result(); res.StatusCode != 200 { - t.Fatal(res.Status) - } - wfs, err := ps.taildrop.WaitingFiles() - if err != nil { - t.Fatal(err) - } - if len(wfs) != 1 { - t.Fatalf("waiting files = %d; want 1", len(wfs)) - } - - if err := ps.taildrop.DeleteFile("foo.txt"); err != nil { - t.Fatal(err) - } - wfs, err = ps.taildrop.WaitingFiles() - if err != nil { - t.Fatal(err) - } - if len(wfs) != 0 { - t.Fatalf("waiting files = %d; want 0", len(wfs)) - } - } -} - func TestPeerAPIReplyToDNSQueries(t *testing.T) { var h peerAPIHandler diff --git a/ipn/ipnlocal/taildrop.go b/ipn/ipnlocal/taildrop.go index db7d8e12a..807304f30 100644 --- a/ipn/ipnlocal/taildrop.go +++ b/ipn/ipnlocal/taildrop.go @@ -1,16 +1,270 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +//go:build !ts_omit_taildrop + package ipnlocal import ( + "cmp" + "context" + "errors" + "io" "maps" "slices" "strings" + "tailscale.com/client/tailscale/apitype" "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" + "tailscale.com/taildrop" + "tailscale.com/tstime" + "tailscale.com/types/empty" + "tailscale.com/util/set" ) +func init() { + hookSetNotifyFilesWaitingLocked = (*LocalBackend).setNotifyFilesWaitingLocked + hookSetPeerStatusTaildropTargetLocked = (*LocalBackend).setPeerStatusTaildropTargetLocked +} + +type taildrop_Manager = taildrop.Manager + +func (b *LocalBackend) newTaildropManager(fileRoot string) *taildrop.Manager { + // TODO(bradfitz): move all this to an ipnext so ipnlocal doesn't need to depend + // on taildrop at all. + if fileRoot == "" { + b.logf("no Taildrop directory configured") + } + return taildrop.ManagerOptions{ + Logf: b.logf, + Clock: tstime.DefaultClock{Clock: b.clock}, + State: b.store, + Dir: fileRoot, + DirectFileMode: b.directFileRoot != "", + SendFileNotify: b.sendFileNotify, + }.New() +} + +func (b *LocalBackend) sendFileNotify() { + var n ipn.Notify + + b.mu.Lock() + for _, wakeWaiter := range b.fileWaiters { + wakeWaiter() + } + apiSrv := b.peerAPIServer + if apiSrv == nil { + b.mu.Unlock() + return + } + + n.IncomingFiles = apiSrv.taildrop.IncomingFiles() + b.mu.Unlock() + + b.send(n) +} + +// TaildropManager returns the taildrop manager for this backend. +// +// TODO(bradfitz): as of 2025-04-15, this is a temporary method during +// refactoring; the plan is for all taildrop code to leave the ipnlocal package +// and move to an extension. Baby steps. +func (b *LocalBackend) TaildropManager() (*taildrop.Manager, error) { + b.mu.Lock() + ps := b.peerAPIServer + b.mu.Unlock() + if ps == nil { + return nil, errors.New("no peer API server initialized") + } + if ps.taildrop == nil { + return nil, errors.New("no taildrop manager initialized") + } + return ps.taildrop, nil +} + +func (b *LocalBackend) taildropOrNil() *taildrop.Manager { + b.mu.Lock() + ps := b.peerAPIServer + b.mu.Unlock() + if ps == nil { + return nil + } + return ps.taildrop +} + +func (b *LocalBackend) setNotifyFilesWaitingLocked(n *ipn.Notify) { + if ps := b.peerAPIServer; ps != nil { + if ps.taildrop.HasFilesWaiting() { + n.FilesWaiting = &empty.Message{} + } + } +} + +func (b *LocalBackend) setPeerStatusTaildropTargetLocked(ps *ipnstate.PeerStatus, p tailcfg.NodeView) { + ps.TaildropTarget = b.taildropTargetStatus(p) +} + +func (b *LocalBackend) removeFileWaiter(handle set.Handle) { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.fileWaiters, handle) +} + +func (b *LocalBackend) addFileWaiter(wakeWaiter context.CancelFunc) set.Handle { + b.mu.Lock() + defer b.mu.Unlock() + return b.fileWaiters.Add(wakeWaiter) +} + +func (b *LocalBackend) WaitingFiles() ([]apitype.WaitingFile, error) { + return b.taildropOrNil().WaitingFiles() +} + +// AwaitWaitingFiles is like WaitingFiles but blocks while ctx is not done, +// waiting for any files to be available. +// +// On return, exactly one of the results will be non-empty or non-nil, +// respectively. +func (b *LocalBackend) AwaitWaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) { + if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 { + return ff, err + } + + for { + gotFile, gotFileCancel := context.WithCancel(context.Background()) + defer gotFileCancel() + + handle := b.addFileWaiter(gotFileCancel) + defer b.removeFileWaiter(handle) + + // Now that we've registered ourselves, check again, in case + // of race. Otherwise there's a small window where we could + // miss a file arrival and wait forever. + if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 { + return ff, err + } + + select { + case <-gotFile.Done(): + if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 { + return ff, err + } + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} + +func (b *LocalBackend) DeleteFile(name string) error { + return b.taildropOrNil().DeleteFile(name) +} + +func (b *LocalBackend) OpenFile(name string) (rc io.ReadCloser, size int64, err error) { + return b.taildropOrNil().OpenFile(name) +} + +// HasCapFileSharing reports whether the current node has the file +// sharing capability enabled. +func (b *LocalBackend) HasCapFileSharing() bool { + // TODO(bradfitz): remove this method and all Taildrop/Taildrive + // references from LocalBackend as part of tailscale/tailscale#12614. + b.mu.Lock() + defer b.mu.Unlock() + return b.capFileSharing +} + +// FileTargets lists nodes that the current node can send files to. +func (b *LocalBackend) FileTargets() ([]*apitype.FileTarget, error) { + var ret []*apitype.FileTarget + + b.mu.Lock() + defer b.mu.Unlock() + nm := b.netMap + if b.state != ipn.Running || nm == nil { + return nil, errors.New("not connected to the tailnet") + } + if !b.capFileSharing { + return nil, errors.New("file sharing not enabled by Tailscale admin") + } + for _, p := range b.peers { + if !b.peerIsTaildropTargetLocked(p) { + continue + } + if p.Hostinfo().OS() == "tvOS" { + continue + } + peerAPI := peerAPIBase(b.netMap, p) + if peerAPI == "" { + continue + } + ret = append(ret, &apitype.FileTarget{ + Node: p.AsStruct(), + PeerAPIURL: peerAPI, + }) + } + slices.SortFunc(ret, func(a, b *apitype.FileTarget) int { + return cmp.Compare(a.Node.Name, b.Node.Name) + }) + return ret, nil +} + +func (b *LocalBackend) taildropTargetStatus(p tailcfg.NodeView) ipnstate.TaildropTargetStatus { + if b.state != ipn.Running { + return ipnstate.TaildropTargetIpnStateNotRunning + } + if b.netMap == nil { + return ipnstate.TaildropTargetNoNetmapAvailable + } + if !b.capFileSharing { + return ipnstate.TaildropTargetMissingCap + } + + if !p.Online().Get() { + return ipnstate.TaildropTargetOffline + } + + if !p.Valid() { + return ipnstate.TaildropTargetNoPeerInfo + } + if b.netMap.User() != p.User() { + // Different user must have the explicit file sharing target capability + if p.Addresses().Len() == 0 || + !b.peerHasCapLocked(p.Addresses().At(0).Addr(), tailcfg.PeerCapabilityFileSharingTarget) { + return ipnstate.TaildropTargetOwnedByOtherUser + } + } + + if p.Hostinfo().OS() == "tvOS" { + return ipnstate.TaildropTargetUnsupportedOS + } + if peerAPIBase(b.netMap, p) == "" { + return ipnstate.TaildropTargetNoPeerAPI + } + return ipnstate.TaildropTargetAvailable +} + +// peerIsTaildropTargetLocked reports whether p is a valid Taildrop file +// recipient from this node according to its ownership and the capabilities in +// the netmap. +// +// b.mu must be locked. +func (b *LocalBackend) peerIsTaildropTargetLocked(p tailcfg.NodeView) bool { + if b.netMap == nil || !p.Valid() { + return false + } + if b.netMap.User() == p.User() { + return true + } + if p.Addresses().Len() > 0 && + b.peerHasCapLocked(p.Addresses().At(0).Addr(), tailcfg.PeerCapabilityFileSharingTarget) { + // Explicitly noted in the netmap ACL caps as a target. + return true + } + return false +} + // UpdateOutgoingFiles updates b.outgoingFiles to reflect the given updates and // sends an ipn.Notify with the full list of outgoingFiles. func (b *LocalBackend) UpdateOutgoingFiles(updates map[string]*ipn.OutgoingFile) { diff --git a/ipn/ipnlocal/taildrop_omit.go b/ipn/ipnlocal/taildrop_omit.go new file mode 100644 index 000000000..07d2d5cc0 --- /dev/null +++ b/ipn/ipnlocal/taildrop_omit.go @@ -0,0 +1,12 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build ts_omit_taildrop + +package ipnlocal + +type taildrop_Manager = struct{} + +func (b *LocalBackend) newTaildropManager(fileRoot string) *taildrop_Manager { + return nil +} diff --git a/ipn/ipnlocal/taildrop_test.go b/ipn/ipnlocal/taildrop_test.go new file mode 100644 index 000000000..9871d5e33 --- /dev/null +++ b/ipn/ipnlocal/taildrop_test.go @@ -0,0 +1,77 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_taildrop + +package ipnlocal + +import ( + "fmt" + "testing" + + "tailscale.com/ipn" + "tailscale.com/tailcfg" + "tailscale.com/tstest/deptest" + "tailscale.com/types/netmap" + "tailscale.com/util/mak" +) + +func TestFileTargets(t *testing.T) { + b := new(LocalBackend) + _, err := b.FileTargets() + if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want { + t.Errorf("before connect: got %q; want %q", got, want) + } + + b.netMap = new(netmap.NetworkMap) + _, err = b.FileTargets() + if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want { + t.Errorf("non-running netmap: got %q; want %q", got, want) + } + + b.state = ipn.Running + _, err = b.FileTargets() + if got, want := fmt.Sprint(err), "file sharing not enabled by Tailscale admin"; got != want { + t.Errorf("without cap: got %q; want %q", got, want) + } + + b.capFileSharing = true + got, err := b.FileTargets() + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("unexpected %d peers", len(got)) + } + + var peerMap map[tailcfg.NodeID]tailcfg.NodeView + mak.NonNil(&peerMap) + var nodeID tailcfg.NodeID + nodeID = 1234 + peer := &tailcfg.Node{ + ID: 1234, + Hostinfo: (&tailcfg.Hostinfo{OS: "tvOS"}).View(), + } + peerMap[nodeID] = peer.View() + b.peers = peerMap + got, err = b.FileTargets() + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("unexpected %d peers", len(got)) + } + // (other cases handled by TestPeerAPIBase above) +} + +func TestOmitTaildropDeps(t *testing.T) { + deptest.DepChecker{ + Tags: "ts_omit_taildrop", + GOOS: "linux", + GOARCH: "amd64", + BadDeps: map[string]string{ + "tailscale.com/taildrop": "should be omitted", + "tailscale.com/feature/taildrop": "should be omitted", + }, + }.Check(t) +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 40e3b7586..94f51d4f2 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -14,12 +14,8 @@ import ( "errors" "fmt" "io" - "maps" - "mime" - "mime/multipart" "net" "net/http" - "net/http/httputil" "net/netip" "net/url" "os" @@ -46,7 +42,6 @@ import ( "tailscale.com/net/netutil" "tailscale.com/net/portmapper" "tailscale.com/tailcfg" - "tailscale.com/taildrop" "tailscale.com/tka" "tailscale.com/tstime" "tailscale.com/types/dnstype" @@ -57,11 +52,9 @@ import ( "tailscale.com/types/tkatype" "tailscale.com/util/clientmetric" "tailscale.com/util/eventbus" - "tailscale.com/util/httphdr" "tailscale.com/util/httpm" "tailscale.com/util/mak" "tailscale.com/util/osdiag" - "tailscale.com/util/progresstracking" "tailscale.com/util/rands" "tailscale.com/util/syspolicy/rsop" "tailscale.com/util/syspolicy/setting" @@ -77,8 +70,6 @@ type LocalAPIHandler func(*Handler, http.ResponseWriter, *http.Request) var handler = map[string]LocalAPIHandler{ // The prefix match handlers end with a slash: "cert/": (*Handler).serveCert, - "file-put/": (*Handler).serveFilePut, - "files/": (*Handler).serveFiles, "policy/": (*Handler).servePolicy, "profiles/": (*Handler).serveProfiles, @@ -106,7 +97,6 @@ var handler = map[string]LocalAPIHandler{ "dns-query": (*Handler).serveDNSQuery, "drive/fileserver-address": (*Handler).serveDriveServerAddr, "drive/shares": (*Handler).serveShares, - "file-targets": (*Handler).serveFileTargets, "goroutines": (*Handler).serveGoroutines, "handle-push-message": (*Handler).serveHandlePushMessage, "id-token": (*Handler).serveIDToken, @@ -203,6 +193,10 @@ type Handler struct { clock tstime.Clock } +func (h *Handler) Logf(format string, args ...any) { + h.logf(format, args...) +} + func (h *Handler) LocalBackend() *ipnlocal.LocalBackend { return h.b } @@ -1087,7 +1081,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 } @@ -1105,7 +1099,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) @@ -1482,67 +1476,10 @@ 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", http.StatusBadRequest) - 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(), http.StatusInternalServerError) - 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", http.StatusBadRequest) - return - } - if r.Method == "DELETE" { - if err := h.b.DeleteFile(name); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) - return - } - rc, size, err := h.b.OpenFile(name) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - 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) { +// WriteErrorJSON writes a JSON object (with a single "error" string field) to w +// with the given error. If err is nil, "unexpected nil error" is used for the +// stringification instead. +func WriteErrorJSON(w http.ResponseWriter, err error) { if err == nil { err = errors.New("unexpected nil error") } @@ -1554,329 +1491,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", http.StatusBadRequest) - 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. -// -// In addition to single file PUTs, this endpoint accepts multipart file -// POSTS encoded as multipart/form-data.The first part should be an -// application/json file that contains a manifest consisting of a JSON array of -// OutgoingFiles which wecan use for tracking progress even before reading the -// file parts. -// -// URL format: -// -// - PUT /localapi/v0/file-put/:stableID/:escaped-filename -// - POST /localapi/v0/file-put/:stableID -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" && r.Method != "POST" { - http.Error(w, "want PUT to put file", http.StatusBadRequest) - return - } - - fts, err := h.b.FileTargets() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - upath, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/file-put/") - if !ok { - http.Error(w, "misconfigured", http.StatusInternalServerError) - return - } - var peerIDStr, filenameEscaped string - if r.Method == "PUT" { - ok := false - peerIDStr, filenameEscaped, ok = strings.Cut(upath, "/") - if !ok { - http.Error(w, "bogus URL", http.StatusBadRequest) - return - } - } else { - peerIDStr = upath - } - peerID := tailcfg.StableNodeID(peerIDStr) - - var ft *apitype.FileTarget - for _, x := range fts { - if x.Node.StableID == peerID { - ft = x - break - } - } - if ft == nil { - http.Error(w, "node not found", http.StatusNotFound) - return - } - dstURL, err := url.Parse(ft.PeerAPIURL) - if err != nil { - http.Error(w, "bogus peer URL", http.StatusInternalServerError) - return - } - - // Periodically report progress of outgoing files. - outgoingFiles := make(map[string]*ipn.OutgoingFile) - t := time.NewTicker(1 * time.Second) - progressUpdates := make(chan ipn.OutgoingFile) - defer close(progressUpdates) - - go func() { - defer t.Stop() - defer h.b.UpdateOutgoingFiles(outgoingFiles) - for { - select { - case u, ok := <-progressUpdates: - if !ok { - return - } - outgoingFiles[u.ID] = &u - case <-t.C: - h.b.UpdateOutgoingFiles(outgoingFiles) - } - } - }() - - switch r.Method { - case "PUT": - file := ipn.OutgoingFile{ - ID: rands.HexString(30), - PeerID: peerID, - Name: filenameEscaped, - DeclaredSize: r.ContentLength, - } - h.singleFilePut(r.Context(), progressUpdates, w, r.Body, dstURL, file) - case "POST": - h.multiFilePost(progressUpdates, w, r, peerID, dstURL) - default: - http.Error(w, "want PUT to put file", http.StatusBadRequest) - return - } -} - -func (h *Handler) multiFilePost(progressUpdates chan (ipn.OutgoingFile), w http.ResponseWriter, r *http.Request, peerID tailcfg.StableNodeID, dstURL *url.URL) { - _, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) - if err != nil { - http.Error(w, fmt.Sprintf("invalid Content-Type for multipart POST: %s", err), http.StatusBadRequest) - return - } - - ww := &multiFilePostResponseWriter{} - defer func() { - if err := ww.Flush(w); err != nil { - h.logf("error: multiFilePostResponseWriter.Flush(): %s", err) - } - }() - - outgoingFilesByName := make(map[string]ipn.OutgoingFile) - first := true - mr := multipart.NewReader(r.Body, params["boundary"]) - for { - part, err := mr.NextPart() - if err == io.EOF { - // No more parts. - return - } else if err != nil { - http.Error(ww, fmt.Sprintf("failed to decode multipart/form-data: %s", err), http.StatusBadRequest) - return - } - - if first { - first = false - if part.Header.Get("Content-Type") != "application/json" { - http.Error(ww, "first MIME part must be a JSON map of filename -> size", http.StatusBadRequest) - return - } - - var manifest []ipn.OutgoingFile - err := json.NewDecoder(part).Decode(&manifest) - if err != nil { - http.Error(ww, fmt.Sprintf("invalid manifest: %s", err), http.StatusBadRequest) - return - } - - for _, file := range manifest { - outgoingFilesByName[file.Name] = file - progressUpdates <- file - } - - continue - } - - if !h.singleFilePut(r.Context(), progressUpdates, ww, part, dstURL, outgoingFilesByName[part.FileName()]) { - return - } - - if ww.statusCode >= 400 { - // put failed, stop immediately - h.logf("error: singleFilePut: failed with status %d", ww.statusCode) - return - } - } -} - -// multiFilePostResponseWriter is a buffering http.ResponseWriter that can be -// reused across multiple singleFilePut calls and then flushed to the client -// when all files have been PUT. -type multiFilePostResponseWriter struct { - header http.Header - statusCode int - body *bytes.Buffer -} - -func (ww *multiFilePostResponseWriter) Header() http.Header { - if ww.header == nil { - ww.header = make(http.Header) - } - return ww.header -} - -func (ww *multiFilePostResponseWriter) WriteHeader(statusCode int) { - ww.statusCode = statusCode -} - -func (ww *multiFilePostResponseWriter) Write(p []byte) (int, error) { - if ww.body == nil { - ww.body = bytes.NewBuffer(nil) - } - return ww.body.Write(p) -} - -func (ww *multiFilePostResponseWriter) Flush(w http.ResponseWriter) error { - if ww.header != nil { - maps.Copy(w.Header(), ww.header) - } - if ww.statusCode > 0 { - w.WriteHeader(ww.statusCode) - } - if ww.body != nil { - _, err := io.Copy(w, ww.body) - return err - } - return nil -} - -func (h *Handler) singleFilePut( - ctx context.Context, - progressUpdates chan (ipn.OutgoingFile), - w http.ResponseWriter, - body io.Reader, - dstURL *url.URL, - outgoingFile ipn.OutgoingFile, -) bool { - outgoingFile.Started = time.Now() - body = progresstracking.NewReader(body, 1*time.Second, func(n int, err error) { - outgoingFile.Sent = int64(n) - progressUpdates <- outgoingFile - }) - - fail := func() { - outgoingFile.Finished = true - outgoingFile.Succeeded = false - progressUpdates <- outgoingFile - } - - // Before we PUT a file we check to see if there are any existing partial file and if so, - // we resume the upload from where we left off by sending the remaining file instead of - // the full file. - var offset int64 - var resumeDuration time.Duration - remainingBody := io.Reader(body) - client := &http.Client{ - Transport: h.b.Dialer().PeerAPITransport(), - Timeout: 10 * time.Second, - } - req, err := http.NewRequestWithContext(ctx, "GET", dstURL.String()+"/v0/put/"+outgoingFile.Name, nil) - if err != nil { - http.Error(w, "bogus peer URL", http.StatusInternalServerError) - fail() - return false - } - switch resp, err := client.Do(req); { - case err != nil: - h.logf("could not fetch remote hashes: %v", err) - case resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotFound: - // noop; implies older peerapi without resume support - case resp.StatusCode != http.StatusOK: - h.logf("fetch remote hashes status code: %d", resp.StatusCode) - default: - resumeStart := time.Now() - dec := json.NewDecoder(resp.Body) - offset, remainingBody, err = taildrop.ResumeReader(body, func() (out taildrop.BlockChecksum, err error) { - err = dec.Decode(&out) - return out, err - }) - if err != nil { - h.logf("reader could not be fully resumed: %v", err) - } - resumeDuration = time.Since(resumeStart).Round(time.Millisecond) - } - - outReq, err := http.NewRequestWithContext(ctx, "PUT", "http://peer/v0/put/"+outgoingFile.Name, remainingBody) - if err != nil { - http.Error(w, "bogus outreq", http.StatusInternalServerError) - fail() - return false - } - outReq.ContentLength = outgoingFile.DeclaredSize - if offset > 0 { - h.logf("resuming put at offset %d after %v", offset, resumeDuration) - rangeHdr, _ := httphdr.FormatRange([]httphdr.Range{{Start: offset, Length: 0}}) - outReq.Header.Set("Range", rangeHdr) - if outReq.ContentLength >= 0 { - outReq.ContentLength -= offset - } - } - - rp := httputil.NewSingleHostReverseProxy(dstURL) - rp.Transport = h.b.Dialer().PeerAPITransport() - rp.ServeHTTP(w, outReq) - - outgoingFile.Finished = true - outgoingFile.Succeeded = true - progressUpdates <- outgoingFile - - return true -} - func (h *Handler) serveSetDNS(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "access denied", http.StatusForbidden) @@ -1889,7 +1503,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") @@ -1980,7 +1594,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") @@ -3013,7 +2627,6 @@ var ( metricInvalidRequests = clientmetric.NewCounter("localapi_invalid_requests") // User-visible LocalAPI endpoints. - metricFilePutCalls = clientmetric.NewCounter("localapi_file_put") metricDebugMetricsCalls = clientmetric.NewCounter("localapi_debugmetric_requests") metricUserMetricsCalls = clientmetric.NewCounter("localapi_usermetric_requests") ) @@ -3026,7 +2639,7 @@ func (h *Handler) serveSuggestExitNode(w http.ResponseWriter, r *http.Request) { } res, err := h.b.SuggestExitNode() if err != nil { - writeErrorJSON(w, err) + WriteErrorJSON(w, err) return } w.Header().Set("Content-Type", "application/json") diff --git a/taildrop/taildrop.go b/taildrop/taildrop.go index 4d14787af..6996dbc4d 100644 --- a/taildrop/taildrop.go +++ b/taildrop/taildrop.go @@ -18,6 +18,7 @@ import ( "path" "path/filepath" "regexp" + "sort" "strconv" "strings" "sync" @@ -239,6 +240,11 @@ func (m *Manager) IncomingFiles() []ipn.PartialFile { }) f.mu.Unlock() } + + sort.Slice(files, func(i, j int) bool { + return files[i].Started.Before(files[j].Started) + }) + return files }