| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418 |
- // Copyright (c) Tailscale Inc & AUTHORS
- // SPDX-License-Identifier: BSD-3-Clause
- package localapi
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "go/ast"
- "go/parser"
- "go/token"
- "io"
- "log"
- "net/http"
- "net/http/httptest"
- "net/netip"
- "net/url"
- "os"
- "slices"
- "strconv"
- "strings"
- "testing"
- "tailscale.com/client/tailscale/apitype"
- "tailscale.com/ipn"
- "tailscale.com/ipn/ipnauth"
- "tailscale.com/ipn/ipnlocal"
- "tailscale.com/ipn/store/mem"
- "tailscale.com/tailcfg"
- "tailscale.com/tsd"
- "tailscale.com/tstest"
- "tailscale.com/types/key"
- "tailscale.com/types/logger"
- "tailscale.com/types/logid"
- "tailscale.com/util/eventbus/eventbustest"
- "tailscale.com/util/slicesx"
- "tailscale.com/wgengine"
- )
- func TestValidHost(t *testing.T) {
- tests := []struct {
- host string
- valid bool
- }{
- {"", true},
- {apitype.LocalAPIHost, true},
- {"localhost:9109", false},
- {"127.0.0.1:9110", false},
- {"[::1]:9111", false},
- {"100.100.100.100:41112", false},
- {"10.0.0.1:41112", false},
- {"37.16.9.210:41112", false},
- }
- for _, test := range tests {
- t.Run(test.host, func(t *testing.T) {
- h := &Handler{}
- if got := h.validHost(test.host); got != test.valid {
- t.Errorf("validHost(%q)=%v, want %v", test.host, got, test.valid)
- }
- })
- }
- }
- func TestSetPushDeviceToken(t *testing.T) {
- tstest.Replace(t, &validLocalHostForTesting, true)
- h := &Handler{
- PermitWrite: true,
- b: &ipnlocal.LocalBackend{},
- }
- s := httptest.NewServer(h)
- defer s.Close()
- c := s.Client()
- want := "my-test-device-token"
- body, err := json.Marshal(apitype.SetPushDeviceTokenRequest{PushDeviceToken: want})
- if err != nil {
- t.Fatal(err)
- }
- req, err := http.NewRequest("POST", s.URL+"/localapi/v0/set-push-device-token", bytes.NewReader(body))
- if err != nil {
- t.Fatal(err)
- }
- res, err := c.Do(req)
- if err != nil {
- t.Fatal(err)
- }
- body, err = io.ReadAll(res.Body)
- if err != nil {
- t.Fatal(err)
- }
- if res.StatusCode != 200 {
- t.Errorf("res.StatusCode=%d, want 200. body: %s", res.StatusCode, body)
- }
- if got := h.b.GetPushDeviceToken(); got != want {
- t.Errorf("hostinfo.PushDeviceToken=%q, want %q", got, want)
- }
- }
- type whoIsBackend struct {
- whoIs func(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
- whoIsNodeKey func(key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
- peerCaps map[netip.Addr]tailcfg.PeerCapMap
- }
- func (b whoIsBackend) WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) {
- return b.whoIs(proto, ipp)
- }
- func (b whoIsBackend) WhoIsNodeKey(k key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) {
- return b.whoIsNodeKey(k)
- }
- func (b whoIsBackend) PeerCaps(ip netip.Addr) tailcfg.PeerCapMap {
- return b.peerCaps[ip]
- }
- // Tests that the WhoIs handler accepts IPs, IP:ports, or nodekeys.
- //
- // From https://github.com/tailscale/tailscale/pull/9714 (a PR that is effectively a bug report)
- //
- // And https://github.com/tailscale/tailscale/issues/12465
- func TestWhoIsArgTypes(t *testing.T) {
- h := &Handler{
- PermitRead: true,
- }
- match := func() (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) {
- return (&tailcfg.Node{
- ID: 123,
- Addresses: []netip.Prefix{
- netip.MustParsePrefix("100.101.102.103/32"),
- },
- }).View(),
- tailcfg.UserProfile{ID: 456, DisplayName: "foo"},
- true
- }
- const keyStr = "nodekey:5c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf261"
- for _, input := range []string{"100.101.102.103", "127.0.0.1:123", keyStr} {
- rec := httptest.NewRecorder()
- t.Run(input, func(t *testing.T) {
- b := whoIsBackend{
- whoIs: func(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) {
- if !strings.Contains(input, ":") {
- want := netip.MustParseAddrPort("100.101.102.103:0")
- if ipp != want {
- t.Fatalf("backend called with %v; want %v", ipp, want)
- }
- }
- return match()
- },
- whoIsNodeKey: func(k key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) {
- if k.String() != keyStr {
- t.Fatalf("backend called with %v; want %v", k, keyStr)
- }
- return match()
- },
- peerCaps: map[netip.Addr]tailcfg.PeerCapMap{
- netip.MustParseAddr("100.101.102.103"): map[tailcfg.PeerCapability][]tailcfg.RawMessage{
- "foo": {`"bar"`},
- },
- },
- }
- h.serveWhoIsWithBackend(rec, httptest.NewRequest("GET", "/v0/whois?addr="+url.QueryEscape(input), nil), b)
- if rec.Code != 200 {
- t.Fatalf("response code %d", rec.Code)
- }
- var res apitype.WhoIsResponse
- if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
- t.Fatalf("parsing response %#q: %v", rec.Body.Bytes(), err)
- }
- if got, want := res.Node.ID, tailcfg.NodeID(123); got != want {
- t.Errorf("res.Node.ID=%v, want %v", got, want)
- }
- if got, want := res.UserProfile.DisplayName, "foo"; got != want {
- t.Errorf("res.UserProfile.DisplayName=%q, want %q", got, want)
- }
- if got, want := len(res.CapMap), 1; got != want {
- t.Errorf("capmap size=%v, want %v", got, want)
- }
- })
- }
- }
- func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
- newHandler := func(connIsLocalAdmin bool) *Handler {
- return &Handler{Actor: &ipnauth.TestActor{LocalAdmin: connIsLocalAdmin}, b: newTestLocalBackend(t)}
- }
- tests := []struct {
- name string
- configIn *ipn.ServeConfig
- h *Handler
- wantErr bool
- }{
- {
- name: "not-path-handler",
- configIn: &ipn.ServeConfig{
- Web: map[ipn.HostPort]*ipn.WebServerConfig{
- "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
- "/": {Proxy: "http://127.0.0.1:3000"},
- }},
- },
- },
- h: newHandler(false),
- wantErr: false,
- },
- {
- name: "path-handler-admin",
- configIn: &ipn.ServeConfig{
- Web: map[ipn.HostPort]*ipn.WebServerConfig{
- "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
- "/": {Path: "/tmp"},
- }},
- },
- },
- h: newHandler(true),
- wantErr: false,
- },
- {
- name: "path-handler-not-admin",
- configIn: &ipn.ServeConfig{
- Web: map[ipn.HostPort]*ipn.WebServerConfig{
- "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
- "/": {Path: "/tmp"},
- }},
- },
- },
- h: newHandler(false),
- wantErr: true,
- },
- }
- for _, tt := range tests {
- for _, goos := range []string{"linux", "windows", "darwin", "illumos", "solaris"} {
- t.Run(goos+"-"+tt.name, func(t *testing.T) {
- err := authorizeServeConfigForGOOSAndUserContext(goos, tt.configIn, tt.h)
- gotErr := err != nil
- if gotErr != tt.wantErr {
- t.Errorf("authorizeServeConfigForGOOSAndUserContext() got error = %v, want error %v", err, tt.wantErr)
- }
- })
- }
- }
- t.Run("other-goos", func(t *testing.T) {
- configIn := &ipn.ServeConfig{
- Web: map[ipn.HostPort]*ipn.WebServerConfig{
- "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
- "/": {Path: "/tmp"},
- }},
- },
- }
- h := newHandler(false)
- err := authorizeServeConfigForGOOSAndUserContext("dos", configIn, h)
- if err != nil {
- t.Errorf("authorizeServeConfigForGOOSAndUserContext() got error = %v, want nil", err)
- }
- })
- }
- func TestServeWatchIPNBus(t *testing.T) {
- tstest.Replace(t, &validLocalHostForTesting, true)
- tests := []struct {
- desc string
- permitRead, permitWrite bool
- mask ipn.NotifyWatchOpt // extra bits in addition to ipn.NotifyInitialState
- wantStatus int
- }{
- {
- desc: "no-permission",
- permitRead: false,
- permitWrite: false,
- wantStatus: http.StatusForbidden,
- },
- {
- desc: "read-initial-state",
- permitRead: true,
- permitWrite: false,
- wantStatus: http.StatusForbidden,
- },
- {
- desc: "read-initial-state-no-private-keys",
- permitRead: true,
- permitWrite: false,
- mask: ipn.NotifyNoPrivateKeys,
- wantStatus: http.StatusOK,
- },
- {
- desc: "read-initial-state-with-private-keys",
- permitRead: true,
- permitWrite: true,
- wantStatus: http.StatusOK,
- },
- }
- for _, tt := range tests {
- t.Run(tt.desc, func(t *testing.T) {
- h := &Handler{
- PermitRead: tt.permitRead,
- PermitWrite: tt.permitWrite,
- b: newTestLocalBackend(t),
- }
- s := httptest.NewServer(h)
- defer s.Close()
- c := s.Client()
- ctx, cancel := context.WithCancel(context.Background())
- req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/localapi/v0/watch-ipn-bus?mask=%d", s.URL, ipn.NotifyInitialState|tt.mask), nil)
- if err != nil {
- t.Fatal(err)
- }
- res, err := c.Do(req)
- if err != nil {
- t.Fatal(err)
- }
- defer res.Body.Close()
- // Cancel the context so that localapi stops streaming IPN bus
- // updates.
- cancel()
- body, err := io.ReadAll(res.Body)
- if err != nil && !errors.Is(err, context.Canceled) {
- t.Fatal(err)
- }
- if res.StatusCode != tt.wantStatus {
- t.Errorf("res.StatusCode=%d, want %d. body: %s", res.StatusCode, tt.wantStatus, body)
- }
- })
- }
- }
- func newTestLocalBackend(t testing.TB) *ipnlocal.LocalBackend {
- var logf logger.Logf = logger.Discard
- sys := tsd.NewSystemWithBus(eventbustest.NewBus(t))
- store := new(mem.Store)
- sys.Set(store)
- eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get())
- if err != nil {
- t.Fatalf("NewFakeUserspaceEngine: %v", err)
- }
- t.Cleanup(eng.Close)
- sys.Set(eng)
- lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0)
- if err != nil {
- t.Fatalf("NewLocalBackend: %v", err)
- }
- t.Cleanup(lb.Shutdown)
- return lb
- }
- func TestKeepItSorted(t *testing.T) {
- // Parse the localapi.go file into an AST.
- fset := token.NewFileSet() // positions are relative to fset
- src, err := os.ReadFile("localapi.go")
- if err != nil {
- log.Fatal(err)
- }
- f, err := parser.ParseFile(fset, "localapi.go", src, 0)
- if err != nil {
- log.Fatal(err)
- }
- getHandler := func() *ast.ValueSpec {
- for _, d := range f.Decls {
- if g, ok := d.(*ast.GenDecl); ok && g.Tok == token.VAR {
- for _, s := range g.Specs {
- if vs, ok := s.(*ast.ValueSpec); ok {
- if len(vs.Names) == 1 && vs.Names[0].Name == "handler" {
- return vs
- }
- }
- }
- }
- }
- return nil
- }
- keys := func() (ret []string) {
- h := getHandler()
- if h == nil {
- t.Fatal("no handler var found")
- }
- cl, ok := h.Values[0].(*ast.CompositeLit)
- if !ok {
- t.Fatalf("handler[0] is %T, want *ast.CompositeLit", h.Values[0])
- }
- for _, e := range cl.Elts {
- kv := e.(*ast.KeyValueExpr)
- strLt := kv.Key.(*ast.BasicLit)
- if strLt.Kind != token.STRING {
- t.Fatalf("got: %T, %q", kv.Key, kv.Key)
- }
- k, err := strconv.Unquote(strLt.Value)
- if err != nil {
- t.Fatalf("unquote: %v", err)
- }
- ret = append(ret, k)
- }
- return
- }
- gotKeys := keys()
- endSlash, noSlash := slicesx.Partition(keys(), func(s string) bool { return strings.HasSuffix(s, "/") })
- if !slices.IsSorted(endSlash) {
- t.Errorf("the items ending in a slash aren't sorted")
- }
- if !slices.IsSorted(noSlash) {
- t.Errorf("the items ending in a slash aren't sorted")
- }
- if !t.Failed() {
- want := append(endSlash, noSlash...)
- if !slices.Equal(gotKeys, want) {
- t.Errorf("items with trailing slashes should precede those without")
- }
- }
- }
|