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>main
parent
dda2c0d2c2
commit
0c78f081a4
@ -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.
|
||||
} |
||||
@ -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" |
||||
@ -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 |
||||
@ -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 |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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)) |
||||
} |
||||
} |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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) |
||||
} |
||||
Loading…
Reference in new issue