From d6fa899eba4978b73c6113318363f570524e55e4 Mon Sep 17 00:00:00 2001 From: Gesa Stupperich Date: Wed, 15 Oct 2025 12:59:10 +0100 Subject: [PATCH] ipn/ipnlocal/serve: remove grant header truncation logic Given that we filter based on the usercaps argument now, truncation should not be necessary anymore. Updates tailscale/corp/#28372 Signed-off-by: Gesa Stupperich --- cmd/tailscale/cli/serve_legacy.go | 2 +- cmd/tailscale/cli/serve_v2.go | 16 ++--- cmd/tailscale/cli/serve_v2_test.go | 18 +++--- ipn/ipn_clone.go | 10 ++-- ipn/ipn_view.go | 12 ++-- ipn/ipnlocal/serve.go | 37 ++---------- ipn/ipnlocal/serve_test.go | 96 ++---------------------------- ipn/serve.go | 2 +- 8 files changed, 41 insertions(+), 152 deletions(-) diff --git a/cmd/tailscale/cli/serve_legacy.go b/cmd/tailscale/cli/serve_legacy.go index 95e518998..5c2d8eefa 100644 --- a/cmd/tailscale/cli/serve_legacy.go +++ b/cmd/tailscale/cli/serve_legacy.go @@ -173,7 +173,7 @@ type serveEnv struct { service tailcfg.ServiceName // service name tun bool // redirect traffic to OS for service allServices bool // apply config file to all services - userCaps []tailcfg.PeerCapability // user capabilities to forward + acceptAppCaps []tailcfg.PeerCapability // app capabilities to forward lc localServeClient // localClient interface, specific to serve // optional stuff for tests: diff --git a/cmd/tailscale/cli/serve_v2.go b/cmd/tailscale/cli/serve_v2.go index 4921bf31f..f822753ac 100644 --- a/cmd/tailscale/cli/serve_v2.go +++ b/cmd/tailscale/cli/serve_v2.go @@ -96,12 +96,12 @@ func (b *bgBoolFlag) String() string { return strconv.FormatBool(b.Value) } -type userCapsFlag struct { +type acceptAppCapsFlag struct { Value *[]tailcfg.PeerCapability } -// Set appends s to the list of userCaps. -func (u *userCapsFlag) Set(s string) error { +// Set appends s to the list of appCaps to accept. +func (u *acceptAppCapsFlag) Set(s string) error { if s == "" { return nil } @@ -109,8 +109,8 @@ func (u *userCapsFlag) Set(s string) error { return nil } -// String returns the string representation of the userCaps slice. -func (u *userCapsFlag) String() string { +// String returns the string representation of the slice of appCaps to accept. +func (u *acceptAppCapsFlag) String() string { s := make([]string, len(*u.Value)) for i, v := range *u.Value { s[i] = string(v) @@ -221,7 +221,7 @@ func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command { fs.UintVar(&e.https, "https", 0, "Expose an HTTPS server at the specified port (default mode)") if subcmd == serve { fs.UintVar(&e.http, "http", 0, "Expose an HTTP server at the specified port") - fs.Var(&userCapsFlag{Value: &e.userCaps}, "usercaps", "User capability to forward to the server (can be specified multiple times)") + fs.Var(&acceptAppCapsFlag{Value: &e.acceptAppCaps}, "accept-app-caps", "App capability to forward to the server (can be specified multiple times)") } fs.UintVar(&e.tcp, "tcp", 0, "Expose a TCP forwarder to forward raw TCP packets at the specified port") fs.UintVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", 0, "Expose a TCP forwarder to forward TLS-terminated TCP packets at the specified port") @@ -492,7 +492,7 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc { if len(args) > 0 { target = args[0] } - err = e.setServe(sc, dnsName, srvType, srvPort, mount, target, funnel, magicDNSSuffix, e.userCaps) + err = e.setServe(sc, dnsName, srvType, srvPort, mount, target, funnel, magicDNSSuffix, e.acceptAppCaps) msg = e.messageForPort(sc, st, dnsName, srvType, srvPort) } if err != nil { @@ -1141,7 +1141,7 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui return err } h.Proxy = t - h.UserCaps = caps + h.AcceptAppCaps = caps } // TODO: validation needs to check nested foreground configs diff --git a/cmd/tailscale/cli/serve_v2_test.go b/cmd/tailscale/cli/serve_v2_test.go index d039c52cc..473acea61 100644 --- a/cmd/tailscale/cli/serve_v2_test.go +++ b/cmd/tailscale/cli/serve_v2_test.go @@ -861,42 +861,42 @@ func TestServeDevConfigMutations(t *testing.T) { name: "forward_grant_header", steps: []step{ { - command: cmd("serve --bg --usercaps=example.com/cap/foo 3000"), + command: cmd("serve --bg --accept-app-caps=example.com/cap/foo 3000"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ "/": { - Proxy: "http://127.0.0.1:3000", - UserCaps: []tailcfg.PeerCapability{"example.com/cap/foo"}, + Proxy: "http://127.0.0.1:3000", + AcceptAppCaps: []tailcfg.PeerCapability{"example.com/cap/foo"}, }, }}, }, }, }, { - command: cmd("serve --bg --usercaps=example.com/cap/foo --usercaps=example.com/cap/bar 3000"), + command: cmd("serve --bg --accept-app-caps=example.com/cap/foo --accept-app-caps=example.com/cap/bar 3000"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ "/": { - Proxy: "http://127.0.0.1:3000", - UserCaps: []tailcfg.PeerCapability{"example.com/cap/foo", "example.com/cap/bar"}, + Proxy: "http://127.0.0.1:3000", + AcceptAppCaps: []tailcfg.PeerCapability{"example.com/cap/foo", "example.com/cap/bar"}, }, }}, }, }, }, { - command: cmd("serve --bg --usercaps=example.com/cap/bar 3000"), + command: cmd("serve --bg --accept-app-caps=example.com/cap/bar 3000"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ "/": { - Proxy: "http://127.0.0.1:3000", - UserCaps: []tailcfg.PeerCapability{"example.com/cap/bar"}, + Proxy: "http://127.0.0.1:3000", + AcceptAppCaps: []tailcfg.PeerCapability{"example.com/cap/bar"}, }, }}, }, diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index 54511094b..8a0a3c833 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -232,16 +232,16 @@ func (src *HTTPHandler) Clone() *HTTPHandler { } dst := new(HTTPHandler) *dst = *src - dst.UserCaps = append(src.UserCaps[:0:0], src.UserCaps...) + dst.AcceptAppCaps = append(src.AcceptAppCaps[:0:0], src.AcceptAppCaps...) return dst } // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _HTTPHandlerCloneNeedsRegeneration = HTTPHandler(struct { - Path string - Proxy string - Text string - UserCaps []tailcfg.PeerCapability + Path string + Proxy string + Text string + AcceptAppCaps []tailcfg.PeerCapability }{}) // Clone makes a deep copy of WebServerConfig. diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index a87b6c42e..61d0dec23 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -892,16 +892,16 @@ func (v HTTPHandlerView) Proxy() string { return v.ж.Proxy } func (v HTTPHandlerView) Text() string { return v.ж.Text } // peer capabilities to forward in grant header, e.g. example.com/cap/mon -func (v HTTPHandlerView) UserCaps() views.Slice[tailcfg.PeerCapability] { - return views.SliceOf(v.ж.UserCaps) +func (v HTTPHandlerView) AcceptAppCaps() views.Slice[tailcfg.PeerCapability] { + return views.SliceOf(v.ж.AcceptAppCaps) } // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _HTTPHandlerViewNeedsRegeneration = HTTPHandler(struct { - Path string - Proxy string - Text string - UserCaps []tailcfg.PeerCapability + Path string + Proxy string + Text string + AcceptAppCaps []tailcfg.PeerCapability }{}) // View returns a read-only view of WebServerConfig. diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 799161a76..5971476de 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -65,7 +65,6 @@ func init() { const ( contentTypeHeader = "Content-Type" grpcBaseContentType = "application/grpc" - grantHeaderMaxSize = 15360 // 15 KiB ) // ErrETagMismatch signals that the given @@ -932,7 +931,7 @@ func encTailscaleHeaderValue(v string) string { } func (b *LocalBackend) addTailscaleGrantHeader(r *httputil.ProxyRequest) { - r.Out.Header.Del("Tailscale-User-Capabilities") + r.Out.Header.Del("Tailscale-App-Capabilities") c, ok := serveHTTPContextKey.ValueOk(r.Out.Context()) if !ok || c.Funnel != nil { @@ -954,37 +953,13 @@ func (b *LocalBackend) addTailscaleGrantHeader(r *httputil.ProxyRequest) { } } - serialized, truncated, err := serializeUpToNBytes(peerCapsFiltered, grantHeaderMaxSize) + peerCapsSerialized, err := json.Marshal(peerCapsFiltered) if err != nil { - b.logf("serve: failed to serialize PeerCapMap: %v", err) + b.logf("serve: failed to serialize filtered PeerCapMap: %v", err) return } - if truncated { - b.logf("serve: serialized PeerCapMap exceeds %d bytes, forwarding truncated PeerCapMap", grantHeaderMaxSize) - } - - r.Out.Header.Set("Tailscale-User-Capabilities", encTailscaleHeaderValue(serialized)) -} -// serializeUpToNBytes serializes capMap. It arbitrarily truncates entries from the capMap -// if the size of the serialized capMap would exceed N bytes. -func serializeUpToNBytes(capMap tailcfg.PeerCapMap, N int) (string, bool, error) { - numBytes := 0 - capped := false - result := tailcfg.PeerCapMap{} - for k, v := range capMap { - numBytes += len(k) + len(v) - if numBytes > N { - capped = true - break - } - result[k] = v - } - marshalled, err := json.Marshal(result) - if err != nil { - return "", false, err - } - return string(marshalled), capped, nil + r.Out.Header.Set("Tailscale-App-Capabilities", encTailscaleHeaderValue(string(peerCapsSerialized))) } // serveWebHandler is an http.HandlerFunc that maps incoming requests to the @@ -1010,12 +985,12 @@ func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "unknown proxy destination", http.StatusInternalServerError) return } - // Inject user capabilities to forward into the request context + // Inject app capabilities to forward into the request context c, ok := serveHTTPContextKey.ValueOk(r.Context()) if !ok { return } - c.PeerCapsFilter = h.UserCaps() + c.PeerCapsFilter = h.AcceptAppCaps() h := p.(http.Handler) // Trim the mount point from the URL path before proxying. (#6571) if r.URL.Path != "/" { diff --git a/ipn/ipnlocal/serve_test.go b/ipn/ipnlocal/serve_test.go index 5d880e185..a72c50c1f 100644 --- a/ipn/ipnlocal/serve_test.go +++ b/ipn/ipnlocal/serve_test.go @@ -828,8 +828,8 @@ func TestServeHTTPProxyGrantHeader(t *testing.T) { Web: map[ipn.HostPort]*ipn.WebServerConfig{ "example.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ "/": { - Proxy: testServ.URL, - UserCaps: []tailcfg.PeerCapability{"example.com/cap/interesting", "example.com/cap/boring"}, + Proxy: testServ.URL, + AcceptAppCaps: []tailcfg.PeerCapability{"example.com/cap/interesting", "example.com/cap/boring"}, }, }}, }, @@ -858,7 +858,7 @@ func TestServeHTTPProxyGrantHeader(t *testing.T) { {"Tailscale-User-Name", "Some One"}, {"Tailscale-User-Profile-Pic", "https://example.com/photo.jpg"}, {"Tailscale-Headers-Info", "https://tailscale.com/s/serve-headers"}, - {"Tailscale-User-Capabilities", `{"example.com/cap/interesting":[{"role":"🐿"}]}`}, + {"Tailscale-App-Capabilities", `{"example.com/cap/interesting":[{"role":"🐿"}]}`}, }, }, { @@ -871,7 +871,7 @@ func TestServeHTTPProxyGrantHeader(t *testing.T) { {"Tailscale-User-Name", ""}, {"Tailscale-User-Profile-Pic", ""}, {"Tailscale-Headers-Info", ""}, - {"Tailscale-User-Capabilities", `{"example.com/cap/boring":[{"role":"Viewer"}]}`}, + {"Tailscale-App-Capabilities", `{"example.com/cap/boring":[{"role":"Viewer"}]}`}, }, }, { @@ -884,7 +884,7 @@ func TestServeHTTPProxyGrantHeader(t *testing.T) { {"Tailscale-User-Name", ""}, {"Tailscale-User-Profile-Pic", ""}, {"Tailscale-Headers-Info", ""}, - {"Tailscale-User-Capabilities", ""}, + {"Tailscale-App-Capabilities", ""}, }, }, } @@ -1327,89 +1327,3 @@ func TestServeGRPCProxy(t *testing.T) { }) } } - -func TestSerialisePeerCapMap(t *testing.T) { - var tests = []struct { - name string - capMap tailcfg.PeerCapMap - maxNumBytes int - wantOneOfSerialized []string - wantTruncated bool - }{ - { - name: "empty cap map", - capMap: tailcfg.PeerCapMap{}, - maxNumBytes: 50, - wantOneOfSerialized: []string{"{}"}, - wantTruncated: false, - }, - { - name: "cap map with one capability", - capMap: tailcfg.PeerCapMap{ - "tailscale.com/cap/kubernetes": []tailcfg.RawMessage{ - `{"impersonate": {"groups": ["tailnet-readers"]}}`, - }, - }, - maxNumBytes: 50, - wantOneOfSerialized: []string{ - `{"tailscale.com/cap/kubernetes":[{"impersonate":{"groups":["tailnet-readers"]}}]}`, - }, - wantTruncated: false, - }, - { - name: "cap map with two capabilities", - capMap: tailcfg.PeerCapMap{ - "foo.com/cap/something": []tailcfg.RawMessage{ - `{"role": "Admin"}`, - }, - "bar.com/cap/other-thing": []tailcfg.RawMessage{ - `{"role": "Viewer"}`, - }, - }, - maxNumBytes: 50, - // Both cap map entries will be included, but they could appear in any order. - wantOneOfSerialized: []string{ - `{"foo.com/cap/something":[{"role":"Admin"}],"bar.com/cap/other-thing":[{"role":"Viewer"}]}`, - `{"bar.com/cap/other-thing":[{"role":"Viewer"}],"foo.com/cap/something":[{"role":"Admin"}]}`, - }, - wantTruncated: false, - }, - { - name: "cap map that should be truncated to stay within size limits", - capMap: tailcfg.PeerCapMap{ - "foo.com/cap/something": []tailcfg.RawMessage{ - `{"role": "Admin"}`, - }, - "bar.com/cap/other-thing": []tailcfg.RawMessage{ - `{"role": "Viewer"}`, - }, - }, - maxNumBytes: 40, - // Only one cap map entry will be included, but we don't know which one. - wantOneOfSerialized: []string{ - `{"foo.com/cap/something":[{"role":"Admin"}]}`, - `{"bar.com/cap/other-thing":[{"role":"Viewer"}]}`, - }, - wantTruncated: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotSerialized, gotCapped, err := serializeUpToNBytes(tt.capMap, tt.maxNumBytes) - - if err != nil { - t.Fatal(err) - } - if gotCapped != tt.wantTruncated { - t.Errorf("got %t, want %t", gotCapped, tt.wantTruncated) - } - for _, wantSerialized := range tt.wantOneOfSerialized { - if gotSerialized == wantSerialized { - return - } - } - t.Errorf("want one of %v, got %q", tt.wantOneOfSerialized, gotSerialized) - }) - } -} diff --git a/ipn/serve.go b/ipn/serve.go index c4a0997d2..3f674d9ed 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -160,7 +160,7 @@ type HTTPHandler struct { Text string `json:",omitempty"` // plaintext to serve (primarily for testing) - UserCaps []tailcfg.PeerCapability `json:",omitempty"` // peer capabilities to forward in grant header, e.g. example.com/cap/mon + AcceptAppCaps []tailcfg.PeerCapability `json:",omitempty"` // peer capabilities to forward in grant header, e.g. example.com/cap/mon // TODO(bradfitz): bool to not enumerate directories? TTL on mapping for // temporary ones? Error codes? Redirects?