mirror of
https://github.com/tailscale/tailscale.git
synced 2025-09-21 21:51:21 +02:00
ipn/ipnlocal/serve: pass grant data in proxy header
Signed-off-by: Gesa Stupperich <gesa@tailscale.com>
This commit is contained in:
parent
782c16c513
commit
a645623df2
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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 != "/" {
|
||||
|
@ -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{
|
||||
|
@ -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?
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user