From a645623df286df0d71eb4936eda2a836c29903c4 Mon Sep 17 00:00:00 2001 From: Gesa Stupperich Date: Mon, 15 Sep 2025 15:42:36 +0100 Subject: [PATCH] ipn/ipnlocal/serve: pass grant data in proxy header Signed-off-by: Gesa Stupperich --- cmd/tailscale/cli/serve_legacy.go | 21 +++-- cmd/tailscale/cli/serve_v2.go | 12 ++- cmd/tailscale/cli/serve_v2_test.go | 21 ++++- ipn/ipn_clone.go | 7 +- ipn/ipn_view.go | 10 +- ipn/ipnlocal/serve.go | 35 +++++++ ipn/ipnlocal/serve_test.go | 142 +++++++++++++++++++++++++++++ ipn/serve.go | 2 + 8 files changed, 229 insertions(+), 21 deletions(-) diff --git a/cmd/tailscale/cli/serve_legacy.go b/cmd/tailscale/cli/serve_legacy.go index 3fbddeabf..2bb783a56 100644 --- a/cmd/tailscale/cli/serve_legacy.go +++ b/cmd/tailscale/cli/serve_legacy.go @@ -156,16 +156,17 @@ type serveEnv struct { json bool // output JSON (status only for now) // v2 specific flags - bg bgBoolFlag // background mode - setPath string // serve path - https uint // HTTP port - http uint // HTTP port - tcp uint // TCP port - tlsTerminatedTCP uint // a TLS terminated TCP port - subcmd serveMode // subcommand - yes bool // update without prompt - service tailcfg.ServiceName // service name - tun bool // redirect traffic to OS for service + bg bgBoolFlag // background mode + setPath string // serve path + https uint // HTTP port + http uint // HTTP port + tcp uint // TCP port + tlsTerminatedTCP uint // a TLS terminated TCP port + subcmd serveMode // subcommand + yes bool // update without prompt + service tailcfg.ServiceName // service name + tun bool // redirect traffic to OS for service + forwardGrantHeaders string // forward grants in headers lc localServeClient // localClient interface, specific to serve diff --git a/cmd/tailscale/cli/serve_v2.go b/cmd/tailscale/cli/serve_v2.go index 903036db4..f37018df3 100644 --- a/cmd/tailscale/cli/serve_v2.go +++ b/cmd/tailscale/cli/serve_v2.go @@ -178,6 +178,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.StringVar(&e.forwardGrantHeaders, "forward-grant-headers", "", "Forward headers containing the values of the specified grants") } 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") @@ -417,7 +418,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) + err = e.setServe(sc, dnsName, srvType, srvPort, mount, target, funnel, magicDNSSuffix, e.forwardGrantHeaders) msg = e.messageForPort(sc, st, dnsName, srvType, srvPort) } if err != nil { @@ -611,12 +612,12 @@ func serveFromPortHandler(tcp *ipn.TCPPortHandler) serveType { } } -func (e *serveEnv) setServe(sc *ipn.ServeConfig, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool, mds string) error { +func (e *serveEnv) setServe(sc *ipn.ServeConfig, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool, mds string, grantHeaders string) error { // update serve config based on the type switch srvType { case serveTypeHTTPS, serveTypeHTTP: useTLS := srvType == serveTypeHTTPS - err := e.applyWebServe(sc, dnsName, srvPort, useTLS, mount, target, mds) + err := e.applyWebServe(sc, dnsName, srvPort, useTLS, mount, target, mds, grantHeaders) if err != nil { return fmt.Errorf("failed apply web serve: %w", err) } @@ -780,7 +781,7 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN return output.String() } -func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, useTLS bool, mount, target string, mds string) error { +func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, useTLS bool, mount, target, mds, grantHeaders string) error { h := new(ipn.HTTPHandler) switch { case strings.HasPrefix(target, "text:"): @@ -814,6 +815,9 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui return err } h.Proxy = t + if grantHeaders != "" { + h.ForwardGrantHeaders = grantHeaders + } } // 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 1deeaf3ea..b63d4f4fb 100644 --- a/cmd/tailscale/cli/serve_v2_test.go +++ b/cmd/tailscale/cli/serve_v2_test.go @@ -814,6 +814,25 @@ func TestServeDevConfigMutations(t *testing.T) { }, }, }, + { + name: "forward_grant_headers", + steps: []step{ + { + command: cmd("serve --bg --forward-grant-headers=example.com/cap/grafana 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", + ForwardGrantHeaders: "example.com/cap/grafana", + }, + }}, + }, + }, + }, + }, + }, } for _, group := range groups { @@ -1966,7 +1985,7 @@ func TestSetServe(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := e.setServe(tt.cfg, tt.dnsName, tt.srvType, tt.srvPort, tt.mountPath, tt.target, tt.allowFunnel, magicDNSSuffix) + err := e.setServe(tt.cfg, tt.dnsName, tt.srvType, tt.srvPort, tt.mountPath, tt.target, tt.allowFunnel, magicDNSSuffix, "") if err != nil && !tt.expectErr { t.Fatalf("got error: %v; did not expect error.", err) } diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index 3d67efc6f..23bee61ab 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -237,9 +237,10 @@ func (src *HTTPHandler) Clone() *HTTPHandler { // 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 + Path string + Proxy string + Text string + ForwardGrantHeaders string }{}) // Clone makes a deep copy of WebServerConfig. diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 170dc409b..daad40dee 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -889,11 +889,15 @@ func (v HTTPHandlerView) Proxy() string { return v.ж.Proxy } // plaintext to serve (primarily for testing) func (v HTTPHandlerView) Text() string { return v.ж.Text } +// name app capability to forward in grant headers +func (v HTTPHandlerView) ForwardGrantHeaders() string { return v.ж.ForwardGrantHeaders } + // 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 + Path string + Proxy string + Text string + ForwardGrantHeaders string }{}) // View returns a read-only view of WebServerConfig. diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 36738b881..9d0d199fa 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -60,6 +60,8 @@ type serveHTTPContext struct { // provides funnel-specific context, nil if not funneled Funnel *funnelFlow + + ForwardGrantHeaders string } // funnelFlow represents a funneled connection initiated via IngressPeer @@ -749,6 +751,7 @@ func (rp *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { r.Out.Host = r.In.Host addProxyForwardedHeaders(r) rp.lb.addTailscaleIdentityHeaders(r) + rp.lb.addCustomGrantHeaders(r) }} // There is no way to autodetect h2c as per RFC 9113 @@ -855,6 +858,27 @@ func (b *LocalBackend) addTailscaleIdentityHeaders(r *httputil.ProxyRequest) { r.Out.Header.Set("Tailscale-Headers-Info", "https://tailscale.com/s/serve-headers") } +func (b *LocalBackend) addCustomGrantHeaders(r *httputil.ProxyRequest) { + c, ok := serveHTTPContextKey.ValueOk(r.Out.Context()) + if !ok { + return + } + + peerCaps := b.PeerCaps(c.SrcAddr.Addr()) + capToForward := tailcfg.PeerCapability(c.ForwardGrantHeaders) + cap, err := tailcfg.UnmarshalCapJSON[map[string]string](peerCaps, capToForward) + if err != nil { + b.logf("couldn't parse capability %s: %v", capToForward, err) + return + } + // take the first entry in the list for now + if len(cap) > 0 { + for k, v := range cap[0] { + r.Out.Header.Set(fmt.Sprintf("Tailscale-User-Capability-%s", k), encTailscaleHeaderValue(v)) + } + } +} + // encTailscaleHeaderValue cleans or encodes as necessary v, to be suitable in // an HTTP header value. See // https://github.com/tailscale/tailscale/issues/11603. @@ -893,6 +917,17 @@ func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "unknown proxy destination", http.StatusInternalServerError) return } + c, ok := serveHTTPContextKey.ValueOk(r.Context()) + if !ok { + return + } + r = r.WithContext(serveHTTPContextKey.WithValue(r.Context(), &serveHTTPContext{ + SrcAddr: c.SrcAddr, + ForVIPService: c.ForVIPService, + DestPort: c.DestPort, + Funnel: c.Funnel, + ForwardGrantHeaders: h.ForwardGrantHeaders(), + })) 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 e2561cba9..fc90db3db 100644 --- a/ipn/ipnlocal/serve_test.go +++ b/ipn/ipnlocal/serve_test.go @@ -24,6 +24,7 @@ import ( "testing" "time" + "tailscale.com/control/controlclient" "tailscale.com/health" "tailscale.com/ipn" "tailscale.com/ipn/store/mem" @@ -37,6 +38,7 @@ import ( "tailscale.com/util/must" "tailscale.com/util/syspolicy/policyclient" "tailscale.com/wgengine" + "tailscale.com/wgengine/filter" ) func TestExpandProxyArg(t *testing.T) { @@ -758,6 +760,143 @@ func TestServeHTTPProxyHeaders(t *testing.T) { } } +func TestServeHTTPProxyHeadersForCustomGrants(t *testing.T) { + b := newTestBackend(t) + + // Configure packet filter for custom app cap grant + // e.g.: "grants": [ + // { + // "src": ["100.150.151.152"], + // "dst": ["*"], + // "app": { + // "example.com/cap/grafana": [{ + // "role": "Admin", + // }], + // }, + // } + // ] + nm := b.NetMap() + matches, err := filter.MatchesFromFilterRules([]tailcfg.FilterRule{{ + SrcIPs: []string{"100.150.151.152"}, + CapGrant: []tailcfg.CapGrant{{ + Dsts: []netip.Prefix{ + netip.MustParsePrefix("100.150.151.151/32"), + }, + CapMap: tailcfg.PeerCapMap{ + "example.com/cap/grafana": []tailcfg.RawMessage{ + "{\"role\": \"Admin\"}", + }, + }, + }}, + }}) + if err != nil { + t.Fatal(err) + } + nm.PacketFilter = matches + b.SetControlClientStatus(nil, controlclient.Status{NetMap: nm}) + + // Start test serve endpoint. + testServ := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + // Piping all the headers through the response writer + // so we can check their values in tests below. + for key, val := range r.Header { + w.Header().Add(key, strings.Join(val, ",")) + } + }, + )) + defer testServ.Close() + + conf := &ipn.ServeConfig{ + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "example.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ + "/": { + Proxy: testServ.URL, + ForwardGrantHeaders: "example.com/cap/grafana", + }, + }}, + }, + } + if err := b.SetServeConfig(conf, ""); err != nil { + t.Fatal(err) + } + + type headerCheck struct { + header string + want string + } + + tests := []struct { + name string + srcIP string + wantHeaders []headerCheck + }{ + { + name: "request-from-user-within-tailnet", + srcIP: "100.150.151.152", + wantHeaders: []headerCheck{ + {"X-Forwarded-Proto", "https"}, + {"X-Forwarded-For", "100.150.151.152"}, + {"Tailscale-User-Login", "someone@example.com"}, + {"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-Capability-role", "Admin"}, + }, + }, + { + name: "request-from-tagged-node-within-tailnet", + srcIP: "100.150.151.153", + wantHeaders: []headerCheck{ + {"X-Forwarded-Proto", "https"}, + {"X-Forwarded-For", "100.150.151.153"}, + {"Tailscale-User-Login", ""}, + {"Tailscale-User-Name", ""}, + {"Tailscale-User-Profile-Pic", ""}, + {"Tailscale-Headers-Info", ""}, + {"Tailscale-User-Capability-role", ""}, + }, + }, + { + name: "request-from-outside-tailnet", + srcIP: "100.160.161.162", + wantHeaders: []headerCheck{ + {"X-Forwarded-Proto", "https"}, + {"X-Forwarded-For", "100.160.161.162"}, + {"Tailscale-User-Login", ""}, + {"Tailscale-User-Name", ""}, + {"Tailscale-User-Profile-Pic", ""}, + {"Tailscale-Headers-Info", ""}, + {"Tailscale-User-Capability-role", ""}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &http.Request{ + URL: &url.URL{Path: "/"}, + TLS: &tls.ConnectionState{ServerName: "example.ts.net"}, + } + req = req.WithContext(serveHTTPContextKey.WithValue(req.Context(), &serveHTTPContext{ + DestPort: 443, + SrcAddr: netip.MustParseAddrPort(tt.srcIP + ":1234"), // random src port for tests + })) + + w := httptest.NewRecorder() + b.serveWebHandler(w, req) + + // Verify the headers. + h := w.Result().Header + for _, c := range tt.wantHeaders { + if got := h.Get(c.header); got != c.want { + t.Errorf("invalid %q header; want=%q, got=%q", c.header, c.want, got) + } + } + }) + } +} + func Test_reverseProxyConfiguration(t *testing.T) { b := newTestBackend(t) type test struct { @@ -916,6 +1055,9 @@ func newTestBackend(t *testing.T, opts ...any) *LocalBackend { b.currentNode().SetNetMap(&netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ Name: "example.ts.net", + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.150.151.151/32"), + }, }).View(), UserProfiles: map[tailcfg.UserID]tailcfg.UserProfileView{ tailcfg.UserID(1): (&tailcfg.UserProfile{ diff --git a/ipn/serve.go b/ipn/serve.go index a0f1334d7..d301c3a66 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -160,6 +160,8 @@ type HTTPHandler struct { Text string `json:",omitempty"` // plaintext to serve (primarily for testing) + ForwardGrantHeaders string `json:",omitempty"` // name app capability to forward in grant headers + // TODO(bradfitz): bool to not enumerate directories? TTL on mapping for // temporary ones? Error codes? Redirects? }