From 331a6d105f92c0934e2dcd33f79703fbf054c66b Mon Sep 17 00:00:00 2001 From: Sonia Appasamy Date: Wed, 10 Jan 2024 15:01:23 -0500 Subject: [PATCH] client/web: add initial types for using peer capabilities Sets up peer capability types for future use within the web client views and APIs. Updates tailscale/corp#16695 Signed-off-by: Sonia Appasamy --- client/web/auth.go | 53 ++++++++++++++ client/web/web.go | 15 ++-- client/web/web_test.go | 158 +++++++++++++++++++++++++++++++++++++++++ tailcfg/tailcfg.go | 3 + 4 files changed, 225 insertions(+), 4 deletions(-) diff --git a/client/web/auth.go b/client/web/auth.go index 696358f2b..9b493a796 100644 --- a/client/web/auth.go +++ b/client/web/auth.go @@ -8,6 +8,7 @@ import ( "crypto/rand" "encoding/base64" "errors" + "fmt" "net/http" "net/url" "strings" @@ -232,3 +233,55 @@ func (s *Server) newSessionID() (string, error) { } return "", errors.New("too many collisions generating new session; please refresh page") } + +type peerCapabilities map[capFeature]bool // value is true if the peer can edit the given feature + +// canEdit is true if the peerCapabilities grant edit access +// to the given feature. +func (p peerCapabilities) canEdit(feature capFeature) bool { + if p == nil { + return false + } + if p[capFeatureAll] { + return true + } + return p[feature] +} + +type capFeature string + +const ( + // The following values should not be edited. + // New caps can be added, but existing ones should not be changed, + // as these exact values are used by users in tailnet policy files. + + capFeatureAll capFeature = "*" // grants peer management of all features + capFeatureFunnel capFeature = "funnel" // grants peer serve/funnel management + capFeatureSSH capFeature = "ssh" // grants peer SSH server management + capFeatureSubnet capFeature = "subnet" // grants peer subnet routes management + capFeatureExitNode capFeature = "exitnode" // grants peer ability to advertise-as and use exit nodes + capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node +) + +type capRule struct { + CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit +} + +// toPeerCapabilities parses out the web ui capabilities from the +// given whois response. +func toPeerCapabilities(whois *apitype.WhoIsResponse) (peerCapabilities, error) { + caps := peerCapabilities{} + if whois == nil { + return caps, nil + } + rules, err := tailcfg.UnmarshalCapJSON[capRule](whois.CapMap, tailcfg.PeerCapabilityWebUI) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal capability: %v", err) + } + for _, c := range rules { + for _, f := range c.CanEdit { + caps[capFeature(strings.ToLower(f))] = true + } + } + return caps, nil +} diff --git a/client/web/web.go b/client/web/web.go index b67cb5645..082b77e2a 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -450,10 +450,11 @@ type authResponse struct { // viewerIdentity is the Tailscale identity of the source node // connected to this web client. type viewerIdentity struct { - LoginName string `json:"loginName"` - NodeName string `json:"nodeName"` - NodeIP string `json:"nodeIP"` - ProfilePicURL string `json:"profilePicUrl,omitempty"` + LoginName string `json:"loginName"` + NodeName string `json:"nodeName"` + NodeIP string `json:"nodeIP"` + ProfilePicURL string `json:"profilePicUrl,omitempty"` + Capabilities peerCapabilities `json:"capabilities"` // features peer is allowed to edit } // serverAPIAuth handles requests to the /api/auth endpoint @@ -464,10 +465,16 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) { session, whois, status, sErr := s.getSession(r) if whois != nil { + caps, err := toPeerCapabilities(whois) + if err != nil { + http.Error(w, sErr.Error(), http.StatusInternalServerError) + return + } resp.ViewerIdentity = &viewerIdentity{ LoginName: whois.UserProfile.LoginName, NodeName: whois.Node.Name, ProfilePicURL: whois.UserProfile.ProfilePicURL, + Capabilities: caps, } if addrs := whois.Node.Addresses; len(addrs) > 0 { resp.ViewerIdentity.NodeIP = addrs[0].Addr().String() diff --git a/client/web/web_test.go b/client/web/web_test.go index 13e2e590b..67e0921d5 100644 --- a/client/web/web_test.go +++ b/client/web/web_test.go @@ -450,6 +450,7 @@ func TestServeAuth(t *testing.T) { NodeName: remoteNode.Node.Name, NodeIP: remoteIP, ProfilePicURL: user.ProfilePicURL, + Capabilities: peerCapabilities{}, } testControlURL := &defaultControlURL @@ -1097,6 +1098,163 @@ func TestRequireTailscaleIP(t *testing.T) { } } +func TestPeerCapabilities(t *testing.T) { + // Testing web.toPeerCapabilities + toPeerCapsTests := []struct { + name string + whois *apitype.WhoIsResponse + wantCaps peerCapabilities + }{ + { + name: "empty-whois", + whois: nil, + wantCaps: peerCapabilities{}, + }, + { + name: "no-webui-caps", + whois: &apitype.WhoIsResponse{ + CapMap: tailcfg.PeerCapMap{ + tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{}, + }, + }, + wantCaps: peerCapabilities{}, + }, + { + name: "one-webui-cap", + whois: &apitype.WhoIsResponse{ + CapMap: tailcfg.PeerCapMap{ + tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ + "{\"canEdit\":[\"ssh\",\"subnet\"]}", + }, + }, + }, + wantCaps: peerCapabilities{ + capFeatureSSH: true, + capFeatureSubnet: true, + }, + }, + { + name: "multiple-webui-cap", + whois: &apitype.WhoIsResponse{ + CapMap: tailcfg.PeerCapMap{ + tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ + "{\"canEdit\":[\"ssh\",\"subnet\"]}", + "{\"canEdit\":[\"subnet\",\"exitnode\",\"*\"]}", + }, + }, + }, + wantCaps: peerCapabilities{ + capFeatureSSH: true, + capFeatureSubnet: true, + capFeatureExitNode: true, + capFeatureAll: true, + }, + }, + { + name: "case=insensitive-caps", + whois: &apitype.WhoIsResponse{ + CapMap: tailcfg.PeerCapMap{ + tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ + "{\"canEdit\":[\"SSH\",\"sUBnet\"]}", + }, + }, + }, + wantCaps: peerCapabilities{ + capFeatureSSH: true, + capFeatureSubnet: true, + }, + }, + { + name: "random-canEdit-contents-dont-error", + whois: &apitype.WhoIsResponse{ + CapMap: tailcfg.PeerCapMap{ + tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ + "{\"canEdit\":[\"unknown-feature\"]}", + }, + }, + }, + wantCaps: peerCapabilities{ + "unknown-feature": true, + }, + }, + { + name: "no-canEdit-section", + whois: &apitype.WhoIsResponse{ + CapMap: tailcfg.PeerCapMap{ + tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ + "{\"canDoSomething\":[\"*\"]}", + }, + }, + }, + wantCaps: peerCapabilities{}, + }, + } + for _, tt := range toPeerCapsTests { + t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) { + got, err := toPeerCapabilities(tt.whois) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + if diff := cmp.Diff(got, tt.wantCaps); diff != "" { + t.Errorf("wrong caps; (-got+want):%v", diff) + } + }) + } + + // Testing web.peerCapabilities.canEdit + canEditTests := []struct { + name string + caps peerCapabilities + wantCanEdit map[capFeature]bool + }{ + { + name: "empty-caps", + caps: nil, + wantCanEdit: map[capFeature]bool{ + capFeatureAll: false, + capFeatureFunnel: false, + capFeatureSSH: false, + capFeatureSubnet: false, + capFeatureExitNode: false, + capFeatureAccount: false, + }, + }, + { + name: "some-caps", + caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true}, + wantCanEdit: map[capFeature]bool{ + capFeatureAll: false, + capFeatureFunnel: false, + capFeatureSSH: true, + capFeatureSubnet: false, + capFeatureExitNode: false, + capFeatureAccount: true, + }, + }, + { + name: "wildcard-in-caps", + caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true}, + wantCanEdit: map[capFeature]bool{ + capFeatureAll: true, + capFeatureFunnel: true, + capFeatureSSH: true, + capFeatureSubnet: true, + capFeatureExitNode: true, + capFeatureAccount: true, + }, + }, + } + for _, tt := range canEditTests { + t.Run("canEdit-"+tt.name, func(t *testing.T) { + for f, want := range tt.wantCanEdit { + if got := tt.caps.canEdit(f); got != want { + t.Errorf("wrong canEdit(%s); got=%v, want=%v", f, got, want) + } + } + }) + } +} + var ( defaultControlURL = "https://controlplane.tailscale.com" testAuthPath = "/a/12345" diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 0168e9b68..c299ebbb0 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -1341,6 +1341,9 @@ const ( PeerCapabilityWakeOnLAN PeerCapability = "https://tailscale.com/cap/wake-on-lan" // PeerCapabilityIngress grants the ability for a peer to send ingress traffic. PeerCapabilityIngress PeerCapability = "https://tailscale.com/cap/ingress" + // PeerCapabilityWebUI grants the ability for a peer to edit features from the + // device Web UI. + PeerCapabilityWebUI PeerCapability = "tailscale.com/cap/webui" ) // NodeCapMap is a map of capabilities to their optional values. It is valid for