tailscale/ipn/ipnlocal/peerapi_test.go
Brad Fitzpatrick 5e268e6153 ipn/ipnlocal: use delete marker files to work around Windows delete problems
If DeleteFile fails on Windows due to another process (anti-virus,
probably) having our file open, instead leave a marker file that the
file is logically deleted, and remove it from API calls and clean it
up lazily later.

Updates tailscale/corp#1626

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-26 10:59:25 -07:00

574 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package ipnlocal
import (
"bytes"
"fmt"
"io"
"io/fs"
"io/ioutil"
"math/rand"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"tailscale.com/tailcfg"
)
type peerAPITestEnv struct {
ph *peerAPIHandler
rr *httptest.ResponseRecorder
logBuf bytes.Buffer
}
func (e *peerAPITestEnv) logf(format string, a ...interface{}) {
fmt.Fprintf(&e.logBuf, format, a...)
}
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 bodyNotContains(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 unexpectedly contains %q; got: %s", sub, body)
}
}
}
func fileHasSize(name string, size int) check {
return func(t *testing.T, e *peerAPITestEnv) {
root := e.ph.ps.rootDir
if root == "" {
t.Errorf("no rootdir; can't check whether %q has size %v", name, size)
return
}
path := filepath.Join(root, name)
if fi, err := os.Stat(path); err != nil {
t.Errorf("fileHasSize(%q, %v): %v", name, size, err)
} else if fi.Size() != int64(size) {
t.Errorf("file %q has size %v; want %v", name, fi.Size(), size)
}
}
}
func fileHasContents(name string, want string) check {
return func(t *testing.T, e *peerAPITestEnv) {
root := e.ph.ps.rootDir
if root == "" {
t.Errorf("no rootdir; can't check contents of %q", name)
return
}
path := filepath.Join(root, name)
got, err := ioutil.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 := 0; i < len(v); i++ {
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 capabilty
omitRoot bool // don't configure
req *http.Request
checks []check
}{
{
name: "not_peer_api",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("GET", "/", nil),
checks: checks(
httpStatus(200),
bodyContains("This is my Tailscale device."),
bodyContains("You are the owner of this node."),
),
},
{
name: "not_peer_api_not_owner",
isSelf: false,
capSharing: true,
req: httptest.NewRequest("GET", "/", nil),
checks: checks(
httpStatus(200),
bodyContains("This is my Tailscale device."),
bodyNotContains("You are the owner of this node."),
),
},
{
name: "peer_api_goroutines_deny",
isSelf: false,
req: httptest.NewRequest("GET", "/v0/goroutines", nil),
checks: checks(httpStatus(403)),
},
{
name: "peer_api_goroutines",
isSelf: true,
req: httptest.NewRequest("GET", "/v0/goroutines", nil),
checks: checks(
httpStatus(200),
bodyContains("ServeHTTP"),
),
},
{
name: "reject_non_owner_put",
isSelf: false,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/foo", nil),
checks: checks(
httpStatus(http.StatusForbidden),
bodyContains("not owner"),
),
},
{
name: "owner_without_cap",
isSelf: true,
capSharing: false,
req: httptest.NewRequest("PUT", "/v0/put/foo", nil),
checks: checks(
httpStatus(http.StatusForbidden),
bodyContains("file sharing not enabled by Tailscale admin"),
),
},
{
name: "owner_with_cap_no_rootdir",
omitRoot: true,
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/foo", nil),
checks: checks(
httpStatus(http.StatusInternalServerError),
bodyContains("no rootdir"),
),
},
{
name: "bad_method",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("POST", "/v0/put/foo", nil),
checks: checks(
httpStatus(405),
bodyContains("expected method PUT"),
),
},
{
name: "put_zero_length",
isSelf: true,
capSharing: true,
req: 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,
req: 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,
req: 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,
req: httptest.NewRequest("PUT", "/v0/put/foo.partial", nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "bad_filename_deleted",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/foo.deleted", nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "bad_filename_dot",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/.", nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "bad_filename_empty",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/", nil),
checks: checks(
httpStatus(400),
bodyContains("empty filename"),
),
},
{
name: "bad_filename_slash",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/foo/bar", nil),
checks: checks(
httpStatus(400),
bodyContains("directories not supported"),
),
},
{
name: "bad_filename_encoded_dot",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll("."), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "bad_filename_encoded_slash",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll("/"), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "bad_filename_encoded_backslash",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll("\\"), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "bad_filename_encoded_dotdot",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll(".."), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "bad_filename_encoded_dotdot_out",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll("foo/../../../../../etc/passwd"), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "put_spaces_and_caps",
isSelf: true,
capSharing: true,
req: 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,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll("Томас и его друзья.mp3"), strings.NewReader("главный озорник")),
checks: checks(
httpStatus(200),
bodyContains("{}"),
fileHasContents("Томас и его друзья.mp3", "главный озорник"),
),
},
{
name: "put_invalid_utf8",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+(hexAll("😜")[:3]), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "put_invalid_null",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/%00", nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "put_invalid_non_printable",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/%01", nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "put_invalid_colon",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll("nul:"), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "put_invalid_surrounding_whitespace",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll(" foo "), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var e peerAPITestEnv
lb := &LocalBackend{
logf: e.logf,
capFileSharing: tt.capSharing,
}
e.ph = &peerAPIHandler{
isSelf: tt.isSelf,
peerNode: &tailcfg.Node{
ComputedName: "some-peer-name",
},
ps: &peerAPIServer{
b: lb,
},
}
var rootDir string
if !tt.omitRoot {
rootDir = t.TempDir()
e.ph.ps.rootDir = rootDir
}
e.rr = httptest.NewRecorder()
e.ph.ServeHTTP(e.rr, tt.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()
ps := &peerAPIServer{
b: &LocalBackend{
logf: t.Logf,
capFileSharing: true,
},
rootDir: dir,
}
ph := &peerAPIHandler{
isSelf: true,
peerNode: &tailcfg.Node{
ComputedName: "some-peer-name",
},
ps: ps,
}
buf := make([]byte, 2<<20)
for i := 0; i < 30; i++ {
rr := httptest.NewRecorder()
ph.ServeHTTP(rr, httptest.NewRequest("PUT", "/v0/put/foo.txt", bytes.NewReader(buf[:rand.Intn(len(buf))])))
if res := rr.Result(); res.StatusCode != 200 {
t.Fatal(res.Status)
}
wfs, err := ps.WaitingFiles()
if err != nil {
t.Fatal(err)
}
if len(wfs) != 1 {
t.Fatalf("waiting files = %d; want 1", len(wfs))
}
if err := ps.DeleteFile("foo.txt"); err != nil {
t.Fatal(err)
}
wfs, err = ps.WaitingFiles()
if err != nil {
t.Fatal(err)
}
if len(wfs) != 0 {
t.Fatalf("waiting files = %d; want 0", len(wfs))
}
}
}
// Tests "foo.jpg.deleted" marks (for Windows).
func TestDeletedMarkers(t *testing.T) {
dir := t.TempDir()
ps := &peerAPIServer{
b: &LocalBackend{
logf: t.Logf,
capFileSharing: true,
},
rootDir: dir,
}
nothingWaiting := func() {
t.Helper()
ps.knownEmpty.Set(false)
if ps.hasFilesWaiting() {
t.Fatal("unexpected files waiting")
}
}
touch := func(base string) {
t.Helper()
if err := touchFile(filepath.Join(dir, base)); err != nil {
t.Fatal(err)
}
}
wantEmptyTempDir := func() {
t.Helper()
if fis, err := ioutil.ReadDir(dir); err != nil {
t.Fatal(err)
} else if len(fis) > 0 && runtime.GOOS != "windows" {
for _, fi := range fis {
t.Errorf("unexpected file in tempdir: %q", fi.Name())
}
}
}
nothingWaiting()
wantEmptyTempDir()
touch("foo.jpg.deleted")
nothingWaiting()
wantEmptyTempDir()
touch("foo.jpg.deleted")
touch("foo.jpg")
nothingWaiting()
wantEmptyTempDir()
touch("foo.jpg.deleted")
touch("foo.jpg")
wf, err := ps.WaitingFiles()
if err != nil {
t.Fatal(err)
}
if len(wf) != 0 {
t.Fatalf("WaitingFiles = %d; want 0", len(wf))
}
wantEmptyTempDir()
touch("foo.jpg.deleted")
touch("foo.jpg")
if rc, _, err := ps.OpenFile("foo.jpg"); err == nil {
rc.Close()
t.Fatal("unexpected foo.jpg open")
}
wantEmptyTempDir()
// And verify basics still work in non-deleted cases.
touch("foo.jpg")
touch("bar.jpg.deleted")
if wf, err := ps.WaitingFiles(); err != nil {
t.Error(err)
} else if len(wf) != 1 {
t.Errorf("WaitingFiles = %d; want 1", len(wf))
} else if wf[0].Name != "foo.jpg" {
t.Errorf("unexpected waiting file %+v", wf[0])
}
if rc, _, err := ps.OpenFile("foo.jpg"); err != nil {
t.Fatal(err)
} else {
rc.Close()
}
}