diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index 112ff9a5d..c2a3e2a92 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/ipn/ipnlocal/peerapi.go @@ -717,6 +717,17 @@ func (h *peerAPIHandler) canPutFile() bool { // canDebug reports whether h can debug this node (goroutines, metrics, // magicsock internal state, etc). func (h *peerAPIHandler) canDebug() bool { + // Reread the selfNode as it may have changed since the peerAPIServer + // was created. + // TODO(maisem): handle this in other places too. + nm := h.ps.b.NetMap() + if nm == nil || nm.SelfNode == nil { + return false + } + if !slices.Contains(nm.SelfNode.Capabilities, tailcfg.CapabilityDebug) { + // This node does not expose debug info. + return false + } return h.isSelf || h.peerHasCap(tailcfg.CapabilityDebugPeer) } diff --git a/ipn/ipnlocal/peerapi_test.go b/ipn/ipnlocal/peerapi_test.go index faf4597c1..234effd9e 100644 --- a/ipn/ipnlocal/peerapi_test.go +++ b/ipn/ipnlocal/peerapi_test.go @@ -24,6 +24,8 @@ import ( "tailscale.com/tailcfg" "tailscale.com/tstest" "tailscale.com/types/logger" + "tailscale.com/types/netmap" + "tailscale.com/util/must" "tailscale.com/wgengine" "tailscale.com/wgengine/filter" ) @@ -111,6 +113,7 @@ func TestHandlePeerAPI(t *testing.T) { name string isSelf bool // the peer sending the request is owned by us capSharing bool // self node has file sharing capability + debugCap bool // self node has debug capability omitRoot bool // don't configure req *http.Request checks []check @@ -138,15 +141,24 @@ func TestHandlePeerAPI(t *testing.T) { ), }, { - name: "peer_api_goroutines_deny", - isSelf: false, - req: httptest.NewRequest("GET", "/v0/goroutines", nil), - checks: checks(httpStatus(403)), + name: "goroutines/deny-self-no-cap", + isSelf: true, + debugCap: false, + req: httptest.NewRequest("GET", "/v0/goroutines", nil), + checks: checks(httpStatus(403)), }, { - name: "peer_api_goroutines", - isSelf: true, - req: httptest.NewRequest("GET", "/v0/goroutines", nil), + name: "goroutines/deny-nonself", + isSelf: false, + debugCap: true, + req: httptest.NewRequest("GET", "/v0/goroutines", nil), + checks: checks(httpStatus(403)), + }, + { + name: "goroutines/accept-self", + isSelf: true, + debugCap: true, + req: httptest.NewRequest("GET", "/v0/goroutines", nil), checks: checks( httpStatus(200), bodyContains("ServeHTTP"), @@ -402,25 +414,28 @@ func TestHandlePeerAPI(t *testing.T) { ), }, { - name: "host-val/bad-ip", - isSelf: true, - req: httptest.NewRequest("GET", "http://12.23.45.66:1234/v0/env", nil), + name: "host-val/bad-ip", + isSelf: true, + debugCap: true, + req: httptest.NewRequest("GET", "http://12.23.45.66:1234/v0/env", nil), checks: checks( httpStatus(403), ), }, { - name: "host-val/no-port", - isSelf: true, - req: httptest.NewRequest("GET", "http://100.100.100.101/v0/env", nil), + name: "host-val/no-port", + isSelf: true, + debugCap: true, + req: httptest.NewRequest("GET", "http://100.100.100.101/v0/env", nil), checks: checks( httpStatus(403), ), }, { - name: "host-val/peer", - isSelf: true, - req: httptest.NewRequest("GET", "http://peer/v0/env", nil), + name: "host-val/peer", + isSelf: true, + debugCap: true, + req: httptest.NewRequest("GET", "http://peer/v0/env", nil), checks: checks( httpStatus(200), ), @@ -428,10 +443,16 @@ func TestHandlePeerAPI(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + selfNode := &tailcfg.Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.100.100.101/32"), + }, + } var e peerAPITestEnv lb := &LocalBackend{ logf: e.logBuf.Logf, capFileSharing: tt.capSharing, + netMap: &netmap.NetworkMap{SelfNode: selfNode}, } e.ph = &peerAPIHandler{ isSelf: tt.isSelf, @@ -439,14 +460,13 @@ func TestHandlePeerAPI(t *testing.T) { ComputedName: "some-peer-name", }, ps: &peerAPIServer{ - b: lb, - selfNode: &tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.100.100.101/32"), - }, - }, + b: lb, + selfNode: selfNode, }, } + if tt.debugCap { + e.ph.ps.selfNode.Capabilities = append(e.ph.ps.selfNode.Capabilities, tailcfg.CapabilityDebug) + } var rootDir string if !tt.omitRoot { rootDir = t.TempDir() diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index ab254a57e..d73faff3c 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -1627,12 +1627,16 @@ type Oauth2Token struct { const ( // These are the capabilities that the self node has as listed in // MapResponse.Node.Capabilities. + // + // We've since started referring to these as "Node Attributes" ("nodeAttrs" + // in the ACL policy file). CapabilityFileSharing = "https://tailscale.com/cap/file-sharing" CapabilityAdmin = "https://tailscale.com/cap/is-admin" CapabilitySSH = "https://tailscale.com/cap/ssh" // feature enabled/available CapabilitySSHRuleIn = "https://tailscale.com/cap/ssh-rule-in" // some SSH rule reach this node CapabilityDataPlaneAuditLogs = "https://tailscale.com/cap/data-plane-audit-logs" // feature enabled + CapabilityDebug = "https://tailscale.com/cap/debug" // exposes debug endpoints over the PeerAPI // Inter-node capabilities as specified in the MapResponse.PacketFilter[].CapGrants.