mirror of
https://github.com/tailscale/tailscale.git
synced 2025-04-18 20:51:45 +00:00
client/web: add logging of device management type for web client (#10492)
Add logging of device management type for the web client auth flow. Namely, this differentiates between viewing a node you do not own, viewing a local tagged node, viewing a remote tagged node, managing a local node, and managing a remote node. Updates https://github.com/tailscale/tailscale/issues/10261 Signed-off-by: Mario Minardi <mario@tailscale.com>
This commit is contained in:
parent
7bdea283bd
commit
21958d2934
@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tailscale.com/client/tailscale/apitype"
|
"tailscale.com/client/tailscale/apitype"
|
||||||
|
"tailscale.com/ipn/ipnstate"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -102,48 +103,48 @@ var (
|
|||||||
//
|
//
|
||||||
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
|
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
|
||||||
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
|
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
|
||||||
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) {
|
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, *ipnstate.Status, error) {
|
||||||
whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||||
status, statusErr := s.lc.StatusWithoutPeers(r.Context())
|
status, statusErr := s.lc.StatusWithoutPeers(r.Context())
|
||||||
switch {
|
switch {
|
||||||
case whoIsErr != nil:
|
case whoIsErr != nil:
|
||||||
return nil, nil, errNotUsingTailscale
|
return nil, nil, status, errNotUsingTailscale
|
||||||
case statusErr != nil:
|
case statusErr != nil:
|
||||||
return nil, whoIs, statusErr
|
return nil, whoIs, nil, statusErr
|
||||||
case status.Self == nil:
|
case status.Self == nil:
|
||||||
return nil, whoIs, errors.New("missing self node in tailscale status")
|
return nil, whoIs, status, errors.New("missing self node in tailscale status")
|
||||||
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
|
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
|
||||||
return nil, whoIs, errTaggedLocalSource
|
return nil, whoIs, status, errTaggedLocalSource
|
||||||
case whoIs.Node.IsTagged():
|
case whoIs.Node.IsTagged():
|
||||||
return nil, whoIs, errTaggedRemoteSource
|
return nil, whoIs, status, errTaggedRemoteSource
|
||||||
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
|
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
|
||||||
return nil, whoIs, errNotOwner
|
return nil, whoIs, status, errNotOwner
|
||||||
}
|
}
|
||||||
srcNode := whoIs.Node.ID
|
srcNode := whoIs.Node.ID
|
||||||
srcUser := whoIs.UserProfile.ID
|
srcUser := whoIs.UserProfile.ID
|
||||||
|
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
if errors.Is(err, http.ErrNoCookie) {
|
if errors.Is(err, http.ErrNoCookie) {
|
||||||
return nil, whoIs, errNoSession
|
return nil, whoIs, status, errNoSession
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, whoIs, err
|
return nil, whoIs, status, err
|
||||||
}
|
}
|
||||||
v, ok := s.browserSessions.Load(cookie.Value)
|
v, ok := s.browserSessions.Load(cookie.Value)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, whoIs, errNoSession
|
return nil, whoIs, status, errNoSession
|
||||||
}
|
}
|
||||||
session := v.(*browserSession)
|
session := v.(*browserSession)
|
||||||
if session.SrcNode != srcNode || session.SrcUser != srcUser {
|
if session.SrcNode != srcNode || session.SrcUser != srcUser {
|
||||||
// In this case the browser cookie is associated with another tailscale node.
|
// In this case the browser cookie is associated with another tailscale node.
|
||||||
// Maybe the source browser's machine was logged out and then back in as a different node.
|
// Maybe the source browser's machine was logged out and then back in as a different node.
|
||||||
// Return errNoSession because there is no session for this user.
|
// Return errNoSession because there is no session for this user.
|
||||||
return nil, whoIs, errNoSession
|
return nil, whoIs, status, errNoSession
|
||||||
} else if session.isExpired(s.timeNow()) {
|
} else if session.isExpired(s.timeNow()) {
|
||||||
// Session expired, remove from session map and return errNoSession.
|
// Session expired, remove from session map and return errNoSession.
|
||||||
s.browserSessions.Delete(session.ID)
|
s.browserSessions.Delete(session.ID)
|
||||||
return nil, whoIs, errNoSession
|
return nil, whoIs, status, errNoSession
|
||||||
}
|
}
|
||||||
return session, whoIs, nil
|
return session, whoIs, status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSession creates a new session associated with the given source user/node,
|
// newSession creates a new session associated with the given source user/node,
|
||||||
|
@ -276,9 +276,6 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.apiHandler.ServeHTTP(w, r)
|
s.apiHandler.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.devMode {
|
|
||||||
s.lc.IncrementCounter(r.Context(), "web_client_page_load", 1)
|
|
||||||
}
|
|
||||||
s.assetsHandler.ServeHTTP(w, r)
|
s.assetsHandler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -328,7 +325,7 @@ func (s *Server) requireTailscaleIP(w http.ResponseWriter, r *http.Request) (han
|
|||||||
// errors to the ResponseWriter itself.
|
// errors to the ResponseWriter itself.
|
||||||
func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||||
if s.mode == ManageServerMode { // client using tailscale auth
|
if s.mode == ManageServerMode { // client using tailscale auth
|
||||||
session, _, err := s.getSession(r)
|
session, _, _, err := s.getSession(r)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, errNotUsingTailscale):
|
case errors.Is(err, errNotUsingTailscale):
|
||||||
// All requests must be made over tailscale.
|
// All requests must be made over tailscale.
|
||||||
@ -404,7 +401,7 @@ type viewerIdentity struct {
|
|||||||
func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
var resp authResponse
|
var resp authResponse
|
||||||
|
|
||||||
session, whois, err := s.getSession(r)
|
session, whois, status, err := s.getSession(r)
|
||||||
switch {
|
switch {
|
||||||
case s.mode == LoginServerMode || errors.Is(err, errNotUsingTailscale):
|
case s.mode == LoginServerMode || errors.Is(err, errNotUsingTailscale):
|
||||||
// not using tailscale, so perform platform auth
|
// not using tailscale, so perform platform auth
|
||||||
@ -426,18 +423,28 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
default:
|
default:
|
||||||
// no additional auth for this distro
|
// no additional auth for this distro
|
||||||
}
|
}
|
||||||
case err != nil && (errors.Is(err, errNotOwner) ||
|
case err != nil && errors.Is(err, errNotOwner):
|
||||||
errors.Is(err, errNotUsingTailscale) ||
|
// Restricted to the readonly view, no auth action to take.
|
||||||
errors.Is(err, errTaggedLocalSource) ||
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_not_owner", 1)
|
||||||
errors.Is(err, errTaggedRemoteSource)):
|
resp.AuthNeeded = ""
|
||||||
// These cases are all restricted to the readonly view.
|
case err != nil && errors.Is(err, errTaggedLocalSource):
|
||||||
// No auth action to take.
|
// Restricted to the readonly view, no auth action to take.
|
||||||
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local_tag", 1)
|
||||||
|
resp.AuthNeeded = ""
|
||||||
|
case err != nil && errors.Is(err, errTaggedRemoteSource):
|
||||||
|
// Restricted to the readonly view, no auth action to take.
|
||||||
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote_tag", 1)
|
||||||
resp.AuthNeeded = ""
|
resp.AuthNeeded = ""
|
||||||
case err != nil && !errors.Is(err, errNoSession):
|
case err != nil && !errors.Is(err, errNoSession):
|
||||||
// Any other error.
|
// Any other error.
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
case session.isAuthorized(s.timeNow()):
|
case session.isAuthorized(s.timeNow()):
|
||||||
|
if whois.Node.StableID == status.Self.ID {
|
||||||
|
s.lc.IncrementCounter(r.Context(), "web_client_managing_local", 1)
|
||||||
|
} else {
|
||||||
|
s.lc.IncrementCounter(r.Context(), "web_client_managing_remote", 1)
|
||||||
|
}
|
||||||
resp.CanManageNode = true
|
resp.CanManageNode = true
|
||||||
resp.AuthNeeded = ""
|
resp.AuthNeeded = ""
|
||||||
default:
|
default:
|
||||||
@ -463,7 +470,7 @@ type newSessionAuthResponse struct {
|
|||||||
|
|
||||||
// serveAPIAuthSessionNew handles requests to the /api/auth/session/new endpoint.
|
// serveAPIAuthSessionNew handles requests to the /api/auth/session/new endpoint.
|
||||||
func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request) {
|
||||||
session, whois, err := s.getSession(r)
|
session, whois, _, err := s.getSession(r)
|
||||||
if err != nil && !errors.Is(err, errNoSession) {
|
if err != nil && !errors.Is(err, errNoSession) {
|
||||||
// Source associated with request not allowed to create
|
// Source associated with request not allowed to create
|
||||||
// a session for this web client.
|
// a session for this web client.
|
||||||
@ -493,7 +500,7 @@ func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
// serveAPIAuthSessionWait handles requests to the /api/auth/session/wait endpoint.
|
// serveAPIAuthSessionWait handles requests to the /api/auth/session/wait endpoint.
|
||||||
func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request) {
|
||||||
session, _, err := s.getSession(r)
|
session, _, _, err := s.getSession(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
|
@ -13,6 +13,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@ -168,7 +169,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
|
|||||||
|
|
||||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||||
defer lal.Close()
|
defer lal.Close()
|
||||||
localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil)
|
localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil, nil)
|
||||||
defer localapi.Close()
|
defer localapi.Close()
|
||||||
go localapi.Serve(lal)
|
go localapi.Serve(lal)
|
||||||
|
|
||||||
@ -305,7 +306,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
|
|||||||
if tt.cookie != "" {
|
if tt.cookie != "" {
|
||||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||||
}
|
}
|
||||||
session, _, err := s.getSession(r)
|
session, _, _, err := s.getSession(r)
|
||||||
if !errors.Is(err, tt.wantError) {
|
if !errors.Is(err, tt.wantError) {
|
||||||
t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
|
t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
|
||||||
}
|
}
|
||||||
@ -336,6 +337,7 @@ func TestAuthorizeRequest(t *testing.T) {
|
|||||||
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
|
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
|
||||||
func() *ipnstate.PeerStatus { return self },
|
func() *ipnstate.PeerStatus { return self },
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
defer localapi.Close()
|
defer localapi.Close()
|
||||||
go localapi.Serve(lal)
|
go localapi.Serve(lal)
|
||||||
@ -445,6 +447,7 @@ func TestServeAuth(t *testing.T) {
|
|||||||
func() *ipn.Prefs {
|
func() *ipn.Prefs {
|
||||||
return &ipn.Prefs{ControlURL: *testControlURL}
|
return &ipn.Prefs{ControlURL: *testControlURL}
|
||||||
},
|
},
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
defer localapi.Close()
|
defer localapi.Close()
|
||||||
go localapi.Serve(lal)
|
go localapi.Serve(lal)
|
||||||
@ -713,6 +716,175 @@ func TestServeAuth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestServeAPIAuthMetricLogging specifically tests metric logging in the serveAPIAuth function.
|
||||||
|
// For each given test case, we assert that the local API received a request to log the expected metric.
|
||||||
|
func TestServeAPIAuthMetricLogging(t *testing.T) {
|
||||||
|
user := &tailcfg.UserProfile{LoginName: "user@example.com", ID: tailcfg.UserID(1)}
|
||||||
|
otherUser := &tailcfg.UserProfile{LoginName: "user2@example.com", ID: tailcfg.UserID(2)}
|
||||||
|
self := &ipnstate.PeerStatus{
|
||||||
|
ID: "self",
|
||||||
|
UserID: user.ID,
|
||||||
|
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")},
|
||||||
|
}
|
||||||
|
remoteIP := "100.100.100.101"
|
||||||
|
remoteNode := &apitype.WhoIsResponse{
|
||||||
|
Node: &tailcfg.Node{
|
||||||
|
Name: "remote-managed",
|
||||||
|
ID: 1,
|
||||||
|
Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")},
|
||||||
|
},
|
||||||
|
UserProfile: user,
|
||||||
|
}
|
||||||
|
remoteTaggedIP := "100.123.100.213"
|
||||||
|
remoteTaggedNode := &apitype.WhoIsResponse{
|
||||||
|
Node: &tailcfg.Node{
|
||||||
|
Name: "remote-tagged",
|
||||||
|
ID: 2,
|
||||||
|
Addresses: []netip.Prefix{netip.MustParsePrefix(remoteTaggedIP + "/32")},
|
||||||
|
Tags: []string{"dev-machine"},
|
||||||
|
},
|
||||||
|
UserProfile: user,
|
||||||
|
}
|
||||||
|
localIP := "100.1.2.3"
|
||||||
|
localNode := &apitype.WhoIsResponse{
|
||||||
|
Node: &tailcfg.Node{
|
||||||
|
Name: "local-managed",
|
||||||
|
ID: 3,
|
||||||
|
StableID: "self",
|
||||||
|
Addresses: []netip.Prefix{netip.MustParsePrefix(localIP + "/32")},
|
||||||
|
},
|
||||||
|
UserProfile: user,
|
||||||
|
}
|
||||||
|
localTaggedIP := "100.1.2.133"
|
||||||
|
localTaggedNode := &apitype.WhoIsResponse{
|
||||||
|
Node: &tailcfg.Node{
|
||||||
|
Name: "local-tagged",
|
||||||
|
ID: 4,
|
||||||
|
StableID: "self",
|
||||||
|
Addresses: []netip.Prefix{netip.MustParsePrefix(localTaggedIP + "/32")},
|
||||||
|
Tags: []string{"prod-machine"},
|
||||||
|
},
|
||||||
|
UserProfile: user,
|
||||||
|
}
|
||||||
|
otherIP := "100.100.2.3"
|
||||||
|
otherNode := &apitype.WhoIsResponse{
|
||||||
|
Node: &tailcfg.Node{
|
||||||
|
Name: "other-node",
|
||||||
|
ID: 5,
|
||||||
|
Addresses: []netip.Prefix{netip.MustParsePrefix(otherIP + "/32")},
|
||||||
|
},
|
||||||
|
UserProfile: otherUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
testControlURL := &defaultControlURL
|
||||||
|
var loggedMetrics []string
|
||||||
|
|
||||||
|
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||||
|
defer lal.Close()
|
||||||
|
localapi := mockLocalAPI(t,
|
||||||
|
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode, localIP: localNode, otherIP: otherNode, localTaggedIP: localTaggedNode, remoteTaggedIP: remoteTaggedNode},
|
||||||
|
func() *ipnstate.PeerStatus { return self },
|
||||||
|
func() *ipn.Prefs {
|
||||||
|
return &ipn.Prefs{ControlURL: *testControlURL}
|
||||||
|
},
|
||||||
|
func(metricName string) {
|
||||||
|
loggedMetrics = append(loggedMetrics, metricName)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
defer localapi.Close()
|
||||||
|
go localapi.Serve(lal)
|
||||||
|
|
||||||
|
timeNow := time.Now()
|
||||||
|
oneHourAgo := timeNow.Add(-time.Hour)
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
mode: ManageServerMode,
|
||||||
|
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||||
|
timeNow: func() time.Time { return timeNow },
|
||||||
|
newAuthURL: mockNewAuthURL,
|
||||||
|
waitAuthURL: mockWaitAuthURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteNodeCookie := "ts-cookie-remote-node"
|
||||||
|
s.browserSessions.Store(remoteNodeCookie, &browserSession{
|
||||||
|
ID: remoteNodeCookie,
|
||||||
|
SrcNode: remoteNode.Node.ID,
|
||||||
|
SrcUser: user.ID,
|
||||||
|
Created: oneHourAgo,
|
||||||
|
AuthID: testAuthPathSuccess,
|
||||||
|
AuthURL: *testControlURL + testAuthPathSuccess,
|
||||||
|
Authenticated: true,
|
||||||
|
})
|
||||||
|
localNodeCookie := "ts-cookie-local-node"
|
||||||
|
s.browserSessions.Store(localNodeCookie, &browserSession{
|
||||||
|
ID: localNodeCookie,
|
||||||
|
SrcNode: localNode.Node.ID,
|
||||||
|
SrcUser: user.ID,
|
||||||
|
Created: oneHourAgo,
|
||||||
|
AuthID: testAuthPathSuccess,
|
||||||
|
AuthURL: *testControlURL + testAuthPathSuccess,
|
||||||
|
Authenticated: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cookie string // cookie attached to request
|
||||||
|
remoteAddr string // remote address to hit
|
||||||
|
|
||||||
|
wantLoggedMetric string // expected metric to be logged
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "managing-remote",
|
||||||
|
cookie: remoteNodeCookie,
|
||||||
|
remoteAddr: remoteIP,
|
||||||
|
wantLoggedMetric: "web_client_managing_remote",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "managing-local",
|
||||||
|
cookie: localNodeCookie,
|
||||||
|
remoteAddr: localIP,
|
||||||
|
wantLoggedMetric: "web_client_managing_local",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "viewing-not-owner",
|
||||||
|
cookie: remoteNodeCookie,
|
||||||
|
remoteAddr: otherIP,
|
||||||
|
wantLoggedMetric: "web_client_viewing_not_owner",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "viewing-local-tagged",
|
||||||
|
cookie: localNodeCookie,
|
||||||
|
remoteAddr: localTaggedIP,
|
||||||
|
wantLoggedMetric: "web_client_viewing_local_tag",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "viewing-remote-tagged",
|
||||||
|
cookie: remoteNodeCookie,
|
||||||
|
remoteAddr: remoteTaggedIP,
|
||||||
|
wantLoggedMetric: "web_client_viewing_remote_tag",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
testControlURL = &defaultControlURL
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "http://100.1.2.3:5252/api/auth", nil)
|
||||||
|
r.RemoteAddr = tt.remoteAddr
|
||||||
|
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.serveAPIAuth(w, r)
|
||||||
|
|
||||||
|
if !slices.Contains(loggedMetrics, tt.wantLoggedMetric) {
|
||||||
|
t.Errorf("expected logged metrics to contain: '%s' but was: '%v'", tt.wantLoggedMetric, loggedMetrics)
|
||||||
|
}
|
||||||
|
loggedMetrics = []string{}
|
||||||
|
|
||||||
|
res := w.Result()
|
||||||
|
defer res.Body.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRequireTailscaleIP(t *testing.T) {
|
func TestRequireTailscaleIP(t *testing.T) {
|
||||||
self := &ipnstate.PeerStatus{
|
self := &ipnstate.PeerStatus{
|
||||||
TailscaleIPs: []netip.Addr{
|
TailscaleIPs: []netip.Addr{
|
||||||
@ -723,7 +895,7 @@ func TestRequireTailscaleIP(t *testing.T) {
|
|||||||
|
|
||||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||||
defer lal.Close()
|
defer lal.Close()
|
||||||
localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil)
|
localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil, nil)
|
||||||
defer localapi.Close()
|
defer localapi.Close()
|
||||||
go localapi.Serve(lal)
|
go localapi.Serve(lal)
|
||||||
|
|
||||||
@ -812,7 +984,7 @@ var (
|
|||||||
// self accepts a function that resolves to a self node status,
|
// self accepts a function that resolves to a self node status,
|
||||||
// so that tests may swap out the /localapi/v0/status response
|
// so that tests may swap out the /localapi/v0/status response
|
||||||
// as desired.
|
// as desired.
|
||||||
func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs) *http.Server {
|
func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs, metricCapture func(string)) *http.Server {
|
||||||
return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/localapi/v0/whois":
|
case "/localapi/v0/whois":
|
||||||
@ -832,6 +1004,19 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
|
|||||||
case "/localapi/v0/prefs":
|
case "/localapi/v0/prefs":
|
||||||
writeJSON(w, prefs())
|
writeJSON(w, prefs())
|
||||||
return
|
return
|
||||||
|
case "/localapi/v0/upload-client-metrics":
|
||||||
|
type metricName struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var metricNames []metricName
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&metricNames); err != nil {
|
||||||
|
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metricCapture(metricNames[0].Name)
|
||||||
|
writeJSON(w, struct{}{})
|
||||||
|
return
|
||||||
default:
|
default:
|
||||||
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
|
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user