mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-30 05:25:35 +00:00
55cd5c575b
On unix systems, the check involves executing sudo, which is slow. Instead of doing it for every incoming request, move the logic into localapi serveServeConfig handler and do it as needed. Updates tailscale/corp#15405 Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
321 lines
8.2 KiB
Go
321 lines
8.2 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package localapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"tailscale.com/client/tailscale/apitype"
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/ipn/ipnlocal"
|
|
"tailscale.com/ipn/store/mem"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/tsd"
|
|
"tailscale.com/tstest"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/types/logid"
|
|
"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(ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
|
|
peerCaps map[netip.Addr]tailcfg.PeerCapMap
|
|
}
|
|
|
|
func (b whoIsBackend) WhoIs(ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) {
|
|
return b.whoIs(ipp)
|
|
}
|
|
|
|
func (b whoIsBackend) PeerCaps(ip netip.Addr) tailcfg.PeerCapMap {
|
|
return b.peerCaps[ip]
|
|
}
|
|
|
|
// Tests that the WhoIs handler accepts either IPs or IP:ports.
|
|
//
|
|
// From https://github.com/tailscale/tailscale/pull/9714 (a PR that is effectively a bug report)
|
|
func TestWhoIsJustIP(t *testing.T) {
|
|
h := &Handler{
|
|
PermitRead: true,
|
|
}
|
|
for _, input := range []string{"100.101.102.103", "127.0.0.1:123"} {
|
|
rec := httptest.NewRecorder()
|
|
t.Run(input, func(t *testing.T) {
|
|
b := whoIsBackend{
|
|
whoIs: func(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 (&tailcfg.Node{
|
|
ID: 123,
|
|
Addresses: []netip.Prefix{
|
|
netip.MustParsePrefix("100.101.102.103/32"),
|
|
},
|
|
}).View(),
|
|
tailcfg.UserProfile{ID: 456, DisplayName: "foo"},
|
|
true
|
|
},
|
|
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)
|
|
|
|
var res apitype.WhoIsResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
|
|
t.Fatal(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{testConnIsLocalAdmin: &connIsLocalAdmin}
|
|
}
|
|
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"} {
|
|
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 := new(tsd.System)
|
|
store := new(mem.Store)
|
|
sys.Set(store)
|
|
eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set)
|
|
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)
|
|
}
|
|
return lb
|
|
}
|