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