feature/taildrop: start moving Taildrop out of LocalBackend

This adds a feature/taildrop package, a ts_omit_taildrop build tag,
and starts moving code to feature/taildrop. In some cases, code
remains where it was but is now behind a build tag. Future changes
will move code to an extension and out of LocalBackend, etc.

Updates #12614

Change-Id: Idf96c61144d1a5f707039ceb2ff59c99f5c1642f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2025-04-15 08:28:48 -07:00
committed by Brad Fitzpatrick
parent dda2c0d2c2
commit 0c78f081a4
21 changed files with 1676 additions and 1246 deletions

View File

@@ -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"

5
feature/taildrop/doc.go Normal file
View File

@@ -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

54
feature/taildrop/ext.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

166
feature/taildrop/peerapi.go Normal file
View File

@@ -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)
}

View File

@@ -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))
}
}
}