2023-01-27 21:37:20 +00:00
|
|
|
|
// Copyright (c) Tailscale Inc & AUTHORS
|
|
|
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
2021-04-20 03:21:48 +00:00
|
|
|
|
|
|
|
|
|
package ipnlocal
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
2022-12-06 20:05:51 +00:00
|
|
|
|
"errors"
|
2021-04-20 03:21:48 +00:00
|
|
|
|
"fmt"
|
2021-04-20 04:00:25 +00:00
|
|
|
|
"io"
|
2021-04-20 04:57:08 +00:00
|
|
|
|
"io/fs"
|
2021-04-22 16:21:30 +00:00
|
|
|
|
"math/rand"
|
2021-04-20 03:21:48 +00:00
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
2022-07-26 03:55:44 +00:00
|
|
|
|
"net/netip"
|
2021-04-20 03:21:48 +00:00
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2021-04-26 16:48:34 +00:00
|
|
|
|
"runtime"
|
2021-04-20 03:21:48 +00:00
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
2022-07-25 03:08:42 +00:00
|
|
|
|
"go4.org/netipx"
|
2021-11-25 17:43:39 +00:00
|
|
|
|
"tailscale.com/ipn"
|
2022-11-09 05:58:10 +00:00
|
|
|
|
"tailscale.com/ipn/store/mem"
|
2021-04-20 03:21:48 +00:00
|
|
|
|
"tailscale.com/tailcfg"
|
2021-09-08 02:27:19 +00:00
|
|
|
|
"tailscale.com/tstest"
|
2021-11-25 17:43:39 +00:00
|
|
|
|
"tailscale.com/types/logger"
|
2022-11-16 18:36:01 +00:00
|
|
|
|
"tailscale.com/types/netmap"
|
2022-11-09 05:58:10 +00:00
|
|
|
|
"tailscale.com/util/must"
|
2021-11-25 17:43:39 +00:00
|
|
|
|
"tailscale.com/wgengine"
|
|
|
|
|
"tailscale.com/wgengine/filter"
|
2021-04-20 03:21:48 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type peerAPITestEnv struct {
|
|
|
|
|
ph *peerAPIHandler
|
|
|
|
|
rr *httptest.ResponseRecorder
|
2021-09-08 02:27:19 +00:00
|
|
|
|
logBuf tstest.MemLogger
|
2021-04-20 03:21:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-20 04:57:08 +00:00
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-20 04:00:25 +00:00
|
|
|
|
func fileHasSize(name string, size int) check {
|
2021-04-20 03:21:48 +00:00
|
|
|
|
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)
|
2021-04-20 04:00:25 +00:00
|
|
|
|
} else if fi.Size() != int64(size) {
|
2021-04-20 03:21:48 +00:00
|
|
|
|
t.Errorf("file %q has size %v; want %v", name, fi.Size(), size)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-20 04:00:25 +00:00
|
|
|
|
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)
|
2022-09-15 12:06:59 +00:00
|
|
|
|
got, err := os.ReadFile(path)
|
2021-04-20 04:00:25 +00:00
|
|
|
|
if err != nil {
|
|
|
|
|
t.Errorf("fileHasContents: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if string(got) != want {
|
|
|
|
|
t.Errorf("file contents = %q; want %q", got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-20 04:57:08 +00:00
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-22 20:01:42 +00:00
|
|
|
|
func TestHandlePeerAPI(t *testing.T) {
|
2022-11-16 16:53:51 +00:00
|
|
|
|
const nodeFQDN = "self-node.tail-scale.ts.net."
|
2021-04-20 03:21:48 +00:00
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
isSelf bool // the peer sending the request is owned by us
|
2022-09-25 18:29:55 +00:00
|
|
|
|
capSharing bool // self node has file sharing capability
|
2022-11-16 18:36:01 +00:00
|
|
|
|
debugCap bool // self node has debug capability
|
2021-04-20 03:21:48 +00:00
|
|
|
|
omitRoot bool // don't configure
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs []*http.Request
|
2021-04-20 03:21:48 +00:00
|
|
|
|
checks []check
|
|
|
|
|
}{
|
2021-04-20 04:57:08 +00:00
|
|
|
|
{
|
|
|
|
|
name: "not_peer_api",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("GET", "/", nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
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,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("GET", "/", nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(200),
|
|
|
|
|
bodyContains("This is my Tailscale device."),
|
|
|
|
|
bodyNotContains("You are the owner of this node."),
|
|
|
|
|
),
|
|
|
|
|
},
|
2021-04-22 20:01:42 +00:00
|
|
|
|
{
|
2022-11-16 18:36:01 +00:00
|
|
|
|
name: "goroutines/deny-self-no-cap",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
debugCap: false,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("GET", "/v0/goroutines", nil)},
|
2022-11-16 18:36:01 +00:00
|
|
|
|
checks: checks(httpStatus(403)),
|
2021-04-22 20:01:42 +00:00
|
|
|
|
},
|
|
|
|
|
{
|
2022-11-16 18:36:01 +00:00
|
|
|
|
name: "goroutines/deny-nonself",
|
|
|
|
|
isSelf: false,
|
|
|
|
|
debugCap: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("GET", "/v0/goroutines", nil)},
|
2022-11-16 18:36:01 +00:00
|
|
|
|
checks: checks(httpStatus(403)),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "goroutines/accept-self",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
debugCap: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("GET", "/v0/goroutines", nil)},
|
2021-04-22 20:01:42 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(200),
|
|
|
|
|
bodyContains("ServeHTTP"),
|
|
|
|
|
),
|
|
|
|
|
},
|
2021-04-20 03:21:48 +00:00
|
|
|
|
{
|
|
|
|
|
name: "reject_non_owner_put",
|
|
|
|
|
isSelf: false,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)},
|
2021-04-20 03:21:48 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(http.StatusForbidden),
|
2022-04-17 15:45:49 +00:00
|
|
|
|
bodyContains("Taildrop access denied"),
|
2021-04-20 03:21:48 +00:00
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "owner_without_cap",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: false,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)},
|
2021-04-20 03:21:48 +00:00
|
|
|
|
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,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)},
|
2021-04-20 03:21:48 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(http.StatusInternalServerError),
|
2021-12-01 23:00:23 +00:00
|
|
|
|
bodyContains("Taildrop disabled; no storage directory"),
|
2021-04-20 03:21:48 +00:00
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
2021-04-20 04:00:25 +00:00
|
|
|
|
name: "bad_method",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("POST", "/v0/put/foo", nil)},
|
2021-04-20 04:00:25 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(405),
|
|
|
|
|
bodyContains("expected method PUT"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "put_zero_length",
|
2021-04-20 03:21:48 +00:00
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)},
|
2021-04-20 03:21:48 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(200),
|
|
|
|
|
bodyContains("{}"),
|
|
|
|
|
fileHasSize("foo", 0),
|
2021-04-20 04:00:25 +00:00
|
|
|
|
fileHasContents("foo", ""),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "put_non_zero_length_content_length",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents"))},
|
2021-04-20 04:00:25 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(200),
|
|
|
|
|
bodyContains("{}"),
|
|
|
|
|
fileHasSize("foo", len("contents")),
|
|
|
|
|
fileHasContents("foo", "contents"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "put_non_zero_length_chunked",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", struct{ io.Reader }{strings.NewReader("contents")})},
|
2021-04-20 04:00:25 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(200),
|
|
|
|
|
bodyContains("{}"),
|
|
|
|
|
fileHasSize("foo", len("contents")),
|
|
|
|
|
fileHasContents("foo", "contents"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "bad_filename_partial",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo.partial", nil)},
|
2021-04-20 04:00:25 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("bad filename"),
|
|
|
|
|
),
|
|
|
|
|
},
|
2021-04-26 16:48:34 +00:00
|
|
|
|
{
|
|
|
|
|
name: "bad_filename_deleted",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo.deleted", nil)},
|
2021-04-26 16:48:34 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("bad filename"),
|
|
|
|
|
),
|
|
|
|
|
},
|
2021-04-20 04:00:25 +00:00
|
|
|
|
{
|
|
|
|
|
name: "bad_filename_dot",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/.", nil)},
|
2021-04-20 04:00:25 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("bad filename"),
|
2021-04-20 03:21:48 +00:00
|
|
|
|
),
|
|
|
|
|
},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
{
|
|
|
|
|
name: "bad_filename_empty",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/", nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("empty filename"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "bad_filename_slash",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo/bar", nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("directories not supported"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "bad_filename_encoded_dot",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("."), nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("bad filename"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "bad_filename_encoded_slash",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("/"), nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("bad filename"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "bad_filename_encoded_backslash",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("\\"), nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("bad filename"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "bad_filename_encoded_dotdot",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll(".."), nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("bad filename"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "bad_filename_encoded_dotdot_out",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("foo/../../../../../etc/passwd"), nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("bad filename"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "put_spaces_and_caps",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("Foo Bar.dat"), strings.NewReader("baz"))},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(200),
|
|
|
|
|
bodyContains("{}"),
|
|
|
|
|
fileHasContents("Foo Bar.dat", "baz"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "put_unicode",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("Томас и его друзья.mp3"), strings.NewReader("главный озорник"))},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(200),
|
|
|
|
|
bodyContains("{}"),
|
|
|
|
|
fileHasContents("Томас и его друзья.mp3", "главный озорник"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "put_invalid_utf8",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+(hexAll("😜")[:3]), nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("bad filename"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "put_invalid_null",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/%00", nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("bad filename"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "put_invalid_non_printable",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/%01", nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("bad filename"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "put_invalid_colon",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("nul:"), nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("bad filename"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "put_invalid_surrounding_whitespace",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
capSharing: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll(" foo "), nil)},
|
2021-04-20 04:57:08 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(400),
|
|
|
|
|
bodyContains("bad filename"),
|
|
|
|
|
),
|
|
|
|
|
},
|
2022-11-16 16:53:51 +00:00
|
|
|
|
{
|
2022-11-16 18:36:01 +00:00
|
|
|
|
name: "host-val/bad-ip",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
debugCap: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("GET", "http://12.23.45.66:1234/v0/env", nil)},
|
2022-11-16 16:53:51 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(403),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
2022-11-16 18:36:01 +00:00
|
|
|
|
name: "host-val/no-port",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
debugCap: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("GET", "http://100.100.100.101/v0/env", nil)},
|
2022-11-16 16:53:51 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(403),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
2022-11-16 18:36:01 +00:00
|
|
|
|
name: "host-val/peer",
|
|
|
|
|
isSelf: true,
|
|
|
|
|
debugCap: true,
|
2023-09-26 17:22:13 +00:00
|
|
|
|
reqs: []*http.Request{httptest.NewRequest("GET", "http://peer/v0/env", nil)},
|
2022-11-16 16:53:51 +00:00
|
|
|
|
checks: checks(
|
|
|
|
|
httpStatus(200),
|
|
|
|
|
),
|
|
|
|
|
},
|
2023-09-26 17:22:13 +00:00
|
|
|
|
{
|
|
|
|
|
name: "bad_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(409),
|
|
|
|
|
bodyContains("file exists"),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "bad_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(409),
|
|
|
|
|
bodyContains("file exists"),
|
|
|
|
|
),
|
|
|
|
|
},
|
2021-04-20 03:21:48 +00:00
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
2022-11-16 18:36:01 +00:00
|
|
|
|
selfNode := &tailcfg.Node{
|
|
|
|
|
Addresses: []netip.Prefix{
|
|
|
|
|
netip.MustParsePrefix("100.100.100.101/32"),
|
|
|
|
|
},
|
|
|
|
|
}
|
2022-11-16 19:07:21 +00:00
|
|
|
|
if tt.debugCap {
|
|
|
|
|
selfNode.Capabilities = append(selfNode.Capabilities, tailcfg.CapabilityDebug)
|
|
|
|
|
}
|
2021-04-20 03:21:48 +00:00
|
|
|
|
var e peerAPITestEnv
|
|
|
|
|
lb := &LocalBackend{
|
2021-09-08 02:27:19 +00:00
|
|
|
|
logf: e.logBuf.Logf,
|
2021-04-22 07:25:00 +00:00
|
|
|
|
capFileSharing: tt.capSharing,
|
2023-08-21 17:53:57 +00:00
|
|
|
|
netMap: &netmap.NetworkMap{SelfNode: selfNode.View()},
|
2023-07-27 19:41:31 +00:00
|
|
|
|
clock: &tstest.Clock{},
|
2021-04-20 03:21:48 +00:00
|
|
|
|
}
|
|
|
|
|
e.ph = &peerAPIHandler{
|
2022-11-16 19:07:21 +00:00
|
|
|
|
isSelf: tt.isSelf,
|
2023-08-21 17:53:57 +00:00
|
|
|
|
selfNode: selfNode.View(),
|
2023-08-18 14:57:44 +00:00
|
|
|
|
peerNode: (&tailcfg.Node{
|
2021-04-20 03:21:48 +00:00
|
|
|
|
ComputedName: "some-peer-name",
|
2023-08-18 14:57:44 +00:00
|
|
|
|
}).View(),
|
2021-04-20 03:21:48 +00:00
|
|
|
|
ps: &peerAPIServer{
|
2022-11-16 19:07:21 +00:00
|
|
|
|
b: lb,
|
2021-04-20 03:21:48 +00:00
|
|
|
|
},
|
|
|
|
|
}
|
2021-04-20 04:57:08 +00:00
|
|
|
|
var rootDir string
|
2021-04-20 03:21:48 +00:00
|
|
|
|
if !tt.omitRoot {
|
2021-04-20 04:57:08 +00:00
|
|
|
|
rootDir = t.TempDir()
|
|
|
|
|
e.ph.ps.rootDir = rootDir
|
2021-04-20 03:21:48 +00:00
|
|
|
|
}
|
2023-09-26 17:22:13 +00:00
|
|
|
|
for _, req := range tt.reqs {
|
|
|
|
|
e.rr = httptest.NewRecorder()
|
|
|
|
|
if req.Host == "example.com" {
|
|
|
|
|
req.Host = "100.100.100.101:12345"
|
|
|
|
|
}
|
|
|
|
|
e.ph.ServeHTTP(e.rr, req)
|
2022-11-16 16:53:51 +00:00
|
|
|
|
}
|
2021-04-20 03:21:48 +00:00
|
|
|
|
for _, f := range tt.checks {
|
|
|
|
|
f(t, &e)
|
|
|
|
|
}
|
2021-04-20 04:57:08 +00:00
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2021-04-20 03:21:48 +00:00
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
2021-04-22 16:21:30 +00:00
|
|
|
|
|
|
|
|
|
// 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{
|
2021-04-22 07:25:00 +00:00
|
|
|
|
logf: t.Logf,
|
|
|
|
|
capFileSharing: true,
|
2023-07-27 19:41:31 +00:00
|
|
|
|
clock: &tstest.Clock{},
|
2021-04-22 16:21:30 +00:00
|
|
|
|
},
|
|
|
|
|
rootDir: dir,
|
|
|
|
|
}
|
|
|
|
|
ph := &peerAPIHandler{
|
|
|
|
|
isSelf: true,
|
2023-08-18 14:57:44 +00:00
|
|
|
|
peerNode: (&tailcfg.Node{
|
2021-04-22 16:21:30 +00:00
|
|
|
|
ComputedName: "some-peer-name",
|
2023-08-18 14:57:44 +00:00
|
|
|
|
}).View(),
|
2023-08-21 17:53:57 +00:00
|
|
|
|
selfNode: (&tailcfg.Node{
|
2022-11-16 19:07:21 +00:00
|
|
|
|
Addresses: []netip.Prefix{netip.MustParsePrefix("100.100.100.101/32")},
|
2023-08-21 17:53:57 +00:00
|
|
|
|
}).View(),
|
2021-04-22 16:21:30 +00:00
|
|
|
|
ps: ps,
|
|
|
|
|
}
|
|
|
|
|
buf := make([]byte, 2<<20)
|
|
|
|
|
for i := 0; i < 30; i++ {
|
|
|
|
|
rr := httptest.NewRecorder()
|
2022-11-16 16:53:51 +00:00
|
|
|
|
ph.ServeHTTP(rr, httptest.NewRequest("PUT", "http://100.100.100.101:123/v0/put/foo.txt", bytes.NewReader(buf[:rand.Intn(len(buf))])))
|
2021-04-22 16:21:30 +00:00
|
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2021-04-26 16:48:34 +00:00
|
|
|
|
|
|
|
|
|
// 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()
|
2022-08-04 04:51:02 +00:00
|
|
|
|
ps.knownEmpty.Store(false)
|
2021-04-26 16:48:34 +00:00
|
|
|
|
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()
|
2022-09-15 12:06:59 +00:00
|
|
|
|
if fis, err := os.ReadDir(dir); err != nil {
|
2021-04-26 16:48:34 +00:00
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
2021-11-25 17:43:39 +00:00
|
|
|
|
|
|
|
|
|
func TestPeerAPIReplyToDNSQueries(t *testing.T) {
|
|
|
|
|
var h peerAPIHandler
|
|
|
|
|
|
|
|
|
|
h.isSelf = true
|
|
|
|
|
if !h.replyToDNSQueries() {
|
|
|
|
|
t.Errorf("for isSelf = false; want true")
|
|
|
|
|
}
|
|
|
|
|
h.isSelf = false
|
2022-07-26 03:55:44 +00:00
|
|
|
|
h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345")
|
2021-11-25 17:43:39 +00:00
|
|
|
|
|
|
|
|
|
eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0)
|
2023-01-31 01:28:13 +00:00
|
|
|
|
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
|
2021-11-25 17:43:39 +00:00
|
|
|
|
h.ps = &peerAPIServer{
|
|
|
|
|
b: &LocalBackend{
|
2022-11-09 05:58:10 +00:00
|
|
|
|
e: eng,
|
|
|
|
|
pm: pm,
|
|
|
|
|
store: pm.Store(),
|
2021-11-25 17:43:39 +00:00
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if h.ps.b.OfferingExitNode() {
|
|
|
|
|
t.Fatal("unexpectedly offering exit node")
|
|
|
|
|
}
|
2022-11-09 05:58:10 +00:00
|
|
|
|
h.ps.b.pm.SetPrefs((&ipn.Prefs{
|
all: convert more code to use net/netip directly
perl -i -npe 's,netaddr.IPPrefixFrom,netip.PrefixFrom,' $(git grep -l -F netaddr.)
perl -i -npe 's,netaddr.IPPortFrom,netip.AddrPortFrom,' $(git grep -l -F netaddr. )
perl -i -npe 's,netaddr.IPPrefix,netip.Prefix,g' $(git grep -l -F netaddr. )
perl -i -npe 's,netaddr.IPPort,netip.AddrPort,g' $(git grep -l -F netaddr. )
perl -i -npe 's,netaddr.IP\b,netip.Addr,g' $(git grep -l -F netaddr. )
perl -i -npe 's,netaddr.IPv6Raw\b,netip.AddrFrom16,g' $(git grep -l -F netaddr. )
goimports -w .
Then delete some stuff from the net/netaddr shim package which is no
longer neeed.
Updates #5162
Change-Id: Ia7a86893fe21c7e3ee1ec823e8aba288d4566cd8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-07-26 04:14:09 +00:00
|
|
|
|
AdvertiseRoutes: []netip.Prefix{
|
2022-07-26 03:55:44 +00:00
|
|
|
|
netip.MustParsePrefix("0.0.0.0/0"),
|
|
|
|
|
netip.MustParsePrefix("::/0"),
|
2021-11-25 17:43:39 +00:00
|
|
|
|
},
|
2023-09-08 16:04:54 +00:00
|
|
|
|
}).View(), "")
|
2021-11-25 17:43:39 +00:00
|
|
|
|
if !h.ps.b.OfferingExitNode() {
|
|
|
|
|
t.Fatal("unexpectedly not offering exit node")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if h.replyToDNSQueries() {
|
|
|
|
|
t.Errorf("unexpectedly doing DNS without filter")
|
|
|
|
|
}
|
|
|
|
|
|
2022-07-25 03:08:42 +00:00
|
|
|
|
h.ps.b.setFilter(filter.NewAllowNone(logger.Discard, new(netipx.IPSet)))
|
2021-11-25 17:43:39 +00:00
|
|
|
|
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.
|
2022-07-26 03:55:44 +00:00
|
|
|
|
h.remoteAddr = netip.MustParseAddrPort("[fe70::1]:12345")
|
2021-11-25 17:43:39 +00:00
|
|
|
|
if !h.replyToDNSQueries() {
|
|
|
|
|
t.Errorf("unexpectedly IPv6 deny; wanted to be a DNS server")
|
|
|
|
|
}
|
|
|
|
|
}
|
2022-12-06 20:05:51 +00:00
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|