// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause package ipnlocal import ( "bytes" "errors" "fmt" "io" "io/fs" "math/rand" "net/http" "net/http/httptest" "net/netip" "os" "path/filepath" "runtime" "strings" "testing" "go4.org/netipx" "tailscale.com/ipn" "tailscale.com/ipn/store/mem" "tailscale.com/tailcfg" "tailscale.com/tstest" "tailscale.com/types/logger" "tailscale.com/types/netmap" "tailscale.com/util/must" "tailscale.com/wgengine" "tailscale.com/wgengine/filter" ) type peerAPITestEnv struct { 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 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 := 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 := 0; i < len(v); i++ { fmt.Fprintf(&sb, "%%%02x", v[i]) } return sb.String() } func TestHandlePeerAPI(t *testing.T) { const nodeFQDN = "self-node.tail-scale.ts.net." 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 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: "goroutines/deny-self-no-cap", isSelf: true, debugCap: false, req: httptest.NewRequest("GET", "/v0/goroutines", nil), checks: checks(httpStatus(403)), }, { name: "goroutines/deny-nonself", isSelf: false, debugCap: true, req: httptest.NewRequest("GET", "/v0/goroutines", nil), checks: checks(httpStatus(403)), }, { name: "goroutines/accept-self", isSelf: true, debugCap: 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("Taildrop access denied"), ), }, { 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("Taildrop disabled; no storage directory"), ), }, { 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"), ), }, { name: "host-val/bad-ip", isSelf: true, debugCap: true, req: httptest.NewRequest("GET", "http://12.23.45.66:1234/v0/env", nil), checks: checks( httpStatus(403), ), }, { name: "host-val/no-port", isSelf: true, debugCap: true, req: httptest.NewRequest("GET", "http://100.100.100.101/v0/env", nil), checks: checks( httpStatus(403), ), }, { name: "host-val/peer", isSelf: true, debugCap: true, req: httptest.NewRequest("GET", "http://peer/v0/env", nil), checks: checks( httpStatus(200), ), }, } 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.Capabilities = append(selfNode.Capabilities, tailcfg.CapabilityDebug) } var e peerAPITestEnv lb := &LocalBackend{ logf: e.logBuf.Logf, capFileSharing: tt.capSharing, netMap: &netmap.NetworkMap{SelfNode: selfNode}, clock: &tstest.Clock{}, } e.ph = &peerAPIHandler{ isSelf: tt.isSelf, selfNode: selfNode, 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() if tt.req.Host == "example.com" { tt.req.Host = "100.100.100.101:12345" } 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, clock: &tstest.Clock{}, }, rootDir: dir, } ph := &peerAPIHandler{ isSelf: true, peerNode: &tailcfg.Node{ ComputedName: "some-peer-name", }, selfNode: &tailcfg.Node{ Addresses: []netip.Prefix{netip.MustParsePrefix("100.100.100.101/32")}, }, ps: ps, } buf := make([]byte, 2<<20) for i := 0; i < 30; i++ { rr := httptest.NewRecorder() ph.ServeHTTP(rr, httptest.NewRequest("PUT", "http://100.100.100.101:123/v0/put/foo.txt", bytes.NewReader(buf[:rand.Intn(len(buf))]))) if res := rr.Result(); res.StatusCode != 200 { t.Fatal(res.Status) } wfs, err := ps.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.Store(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 := os.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() } } func TestPeerAPIReplyToDNSQueries(t *testing.T) { var h peerAPIHandler h.isSelf = true if !h.replyToDNSQueries() { t.Errorf("for isSelf = false; want true") } h.isSelf = false h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345") eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0) pm := must.Get(newProfileManager(new(mem.Store), t.Logf)) h.ps = &peerAPIServer{ b: &LocalBackend{ e: eng, pm: pm, store: pm.Store(), }, } if h.ps.b.OfferingExitNode() { t.Fatal("unexpectedly offering exit node") } h.ps.b.pm.SetPrefs((&ipn.Prefs{ AdvertiseRoutes: []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), }, }).View()) if !h.ps.b.OfferingExitNode() { t.Fatal("unexpectedly not offering exit node") } if h.replyToDNSQueries() { t.Errorf("unexpectedly doing DNS without filter") } h.ps.b.setFilter(filter.NewAllowNone(logger.Discard, new(netipx.IPSet))) if h.replyToDNSQueries() { t.Errorf("unexpectedly doing DNS without filter") } f := filter.NewAllowAllForTest(logger.Discard) h.ps.b.setFilter(f) if !h.replyToDNSQueries() { t.Errorf("unexpectedly deny; wanted to be a DNS server") } // Also test IPv6. h.remoteAddr = netip.MustParseAddrPort("[fe70::1]:12345") if !h.replyToDNSQueries() { t.Errorf("unexpectedly IPv6 deny; wanted to be a DNS server") } } func TestRedactErr(t *testing.T) { testCases := []struct { name string err func() error want string }{ { name: "PathError", err: func() error { return &os.PathError{ Op: "open", Path: "/tmp/sensitive.txt", Err: fs.ErrNotExist, } }, want: `open redacted.41360718: file does not exist`, }, { name: "LinkError", err: func() error { return &os.LinkError{ Op: "symlink", Old: "/tmp/sensitive.txt", New: "/tmp/othersensitive.txt", Err: fs.ErrNotExist, } }, want: `symlink redacted.41360718 redacted.6bcf093a: file does not exist`, }, { name: "something else", err: func() error { return errors.New("i am another error type") }, want: `i am another error type`, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // For debugging var i int for err := tc.err(); err != nil; err = errors.Unwrap(err) { t.Logf("%d: %T @ %p", i, err, err) i++ } t.Run("Root", func(t *testing.T) { got := redactErr(tc.err()).Error() if got != tc.want { t.Errorf("err = %q; want %q", got, tc.want) } }) t.Run("Wrapped", func(t *testing.T) { wrapped := fmt.Errorf("wrapped error: %w", tc.err()) want := "wrapped error: " + tc.want got := redactErr(wrapped).Error() if got != want { t.Errorf("err = %q; want %q", got, want) } }) }) } }