mirror of
				https://github.com/tailscale/tailscale.git
				synced 2025-11-03 16:31:20 +00:00 
			
		
		
		
	cmd/k8s-operator,ssh/tailssh,tsnet: optionally record kubectl exec sessions The Kubernetes operator's API server proxy, when it receives a request for 'kubectl exec' session now reads 'RecorderAddrs', 'EnforceRecorder' fields from tailcfg.KubernetesCapRule. If 'RecorderAddrs' is set to one or more addresses (of a tsrecorder instance(s)), it attempts to connect to those and sends the session contents to the recorder before forwarding the request to the kube API server. If connection cannot be established or fails midway, it is only allowed if 'EnforceRecorder' is not true (fail open). Updates tailscale/corp#19821 Signed-off-by: Irbe Krumina <irbe@tailscale.com> Co-authored-by: Maisem Ali <maisem@tailscale.com>
		
			
				
	
	
		
			200 lines
		
	
	
		
			6.0 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			200 lines
		
	
	
		
			6.0 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
// Copyright (c) Tailscale Inc & AUTHORS
 | 
						|
// SPDX-License-Identifier: BSD-3-Clause
 | 
						|
 | 
						|
//go:build !plan9
 | 
						|
 | 
						|
package main
 | 
						|
 | 
						|
import (
 | 
						|
	"net/http"
 | 
						|
	"net/netip"
 | 
						|
	"reflect"
 | 
						|
	"testing"
 | 
						|
 | 
						|
	"github.com/google/go-cmp/cmp"
 | 
						|
	"go.uber.org/zap"
 | 
						|
	"tailscale.com/client/tailscale/apitype"
 | 
						|
	"tailscale.com/tailcfg"
 | 
						|
	"tailscale.com/util/must"
 | 
						|
)
 | 
						|
 | 
						|
func TestImpersonationHeaders(t *testing.T) {
 | 
						|
	zl, err := zap.NewDevelopment()
 | 
						|
	if err != nil {
 | 
						|
		t.Fatal(err)
 | 
						|
	}
 | 
						|
	tests := []struct {
 | 
						|
		name     string
 | 
						|
		emailish string
 | 
						|
		tags     []string
 | 
						|
		capMap   tailcfg.PeerCapMap
 | 
						|
 | 
						|
		wantHeaders http.Header
 | 
						|
	}{
 | 
						|
		{
 | 
						|
			name:     "user",
 | 
						|
			emailish: "foo@example.com",
 | 
						|
			wantHeaders: http.Header{
 | 
						|
				"Impersonate-User": {"foo@example.com"},
 | 
						|
			},
 | 
						|
		},
 | 
						|
		{
 | 
						|
			name:     "tagged",
 | 
						|
			emailish: "tagged-device",
 | 
						|
			tags:     []string{"tag:foo", "tag:bar"},
 | 
						|
			wantHeaders: http.Header{
 | 
						|
				"Impersonate-User":  {"node.ts.net"},
 | 
						|
				"Impersonate-Group": {"tag:foo", "tag:bar"},
 | 
						|
			},
 | 
						|
		},
 | 
						|
		{
 | 
						|
			name:     "user-with-cap",
 | 
						|
			emailish: "foo@example.com",
 | 
						|
			capMap: tailcfg.PeerCapMap{
 | 
						|
				tailcfg.PeerCapabilityKubernetes: {
 | 
						|
					tailcfg.RawMessage(`{"impersonate":{"groups":["group1","group2"]}}`),
 | 
						|
					tailcfg.RawMessage(`{"impersonate":{"groups":["group1","group3"]}}`), // One group is duplicated.
 | 
						|
					tailcfg.RawMessage(`{"impersonate":{"groups":["group4"]}}`),
 | 
						|
					tailcfg.RawMessage(`{"impersonate":{"groups":["group2"]}}`), // duplicate
 | 
						|
 | 
						|
					// These should be ignored, but should parse correctly.
 | 
						|
					tailcfg.RawMessage(`{}`),
 | 
						|
					tailcfg.RawMessage(`{"impersonate":{}}`),
 | 
						|
					tailcfg.RawMessage(`{"impersonate":{"groups":[]}}`),
 | 
						|
				},
 | 
						|
			},
 | 
						|
			wantHeaders: http.Header{
 | 
						|
				"Impersonate-Group": {"group1", "group2", "group3", "group4"},
 | 
						|
				"Impersonate-User":  {"foo@example.com"},
 | 
						|
			},
 | 
						|
		},
 | 
						|
		{
 | 
						|
			name:     "tagged-with-cap",
 | 
						|
			emailish: "tagged-device",
 | 
						|
			tags:     []string{"tag:foo", "tag:bar"},
 | 
						|
			capMap: tailcfg.PeerCapMap{
 | 
						|
				tailcfg.PeerCapabilityKubernetes: {
 | 
						|
					tailcfg.RawMessage(`{"impersonate":{"groups":["group1"]}}`),
 | 
						|
				},
 | 
						|
			},
 | 
						|
			wantHeaders: http.Header{
 | 
						|
				"Impersonate-Group": {"group1"},
 | 
						|
				"Impersonate-User":  {"node.ts.net"},
 | 
						|
			},
 | 
						|
		},
 | 
						|
		{
 | 
						|
			name:     "mix-of-caps",
 | 
						|
			emailish: "tagged-device",
 | 
						|
			tags:     []string{"tag:foo", "tag:bar"},
 | 
						|
			capMap: tailcfg.PeerCapMap{
 | 
						|
				tailcfg.PeerCapabilityKubernetes: {
 | 
						|
					tailcfg.RawMessage(`{"impersonate":{"groups":["group1"]},"recorder":["tag:foo"],"enforceRecorder":true}`),
 | 
						|
				},
 | 
						|
			},
 | 
						|
			wantHeaders: http.Header{
 | 
						|
				"Impersonate-Group": {"group1"},
 | 
						|
				"Impersonate-User":  {"node.ts.net"},
 | 
						|
			},
 | 
						|
		},
 | 
						|
		{
 | 
						|
			name:     "bad-cap",
 | 
						|
			emailish: "tagged-device",
 | 
						|
			tags:     []string{"tag:foo", "tag:bar"},
 | 
						|
			capMap: tailcfg.PeerCapMap{
 | 
						|
				tailcfg.PeerCapabilityKubernetes: {
 | 
						|
					tailcfg.RawMessage(`[]`),
 | 
						|
				},
 | 
						|
			},
 | 
						|
			wantHeaders: http.Header{},
 | 
						|
		},
 | 
						|
	}
 | 
						|
 | 
						|
	for _, tc := range tests {
 | 
						|
		r := must.Get(http.NewRequest("GET", "https://op.ts.net/api/foo", nil))
 | 
						|
		r = r.WithContext(whoIsKey.WithValue(r.Context(), &apitype.WhoIsResponse{
 | 
						|
			Node: &tailcfg.Node{
 | 
						|
				Name: "node.ts.net",
 | 
						|
				Tags: tc.tags,
 | 
						|
			},
 | 
						|
			UserProfile: &tailcfg.UserProfile{
 | 
						|
				LoginName: tc.emailish,
 | 
						|
			},
 | 
						|
			CapMap: tc.capMap,
 | 
						|
		}))
 | 
						|
		addImpersonationHeaders(r, zl.Sugar())
 | 
						|
 | 
						|
		if d := cmp.Diff(tc.wantHeaders, r.Header); d != "" {
 | 
						|
			t.Errorf("unexpected header (-want +got):\n%s", d)
 | 
						|
		}
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func Test_determineRecorderConfig(t *testing.T) {
 | 
						|
	addr1, addr2 := netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"), netip.MustParseAddrPort("100.99.99.99:80")
 | 
						|
	tests := []struct {
 | 
						|
		name                  string
 | 
						|
		wantFailOpen          bool
 | 
						|
		wantRecorderAddresses []netip.AddrPort
 | 
						|
		who                   *apitype.WhoIsResponse
 | 
						|
	}{
 | 
						|
		{
 | 
						|
			name:                  "two_ips_fail_closed",
 | 
						|
			who:                   whoResp(map[string][]string{string(tailcfg.PeerCapabilityKubernetes): {`{"recorderAddrs":["[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80","100.99.99.99:80"],"enforceRecorder":true}`}}),
 | 
						|
			wantRecorderAddresses: []netip.AddrPort{addr1, addr2},
 | 
						|
		},
 | 
						|
		{
 | 
						|
			name:                  "two_ips_fail_open",
 | 
						|
			who:                   whoResp(map[string][]string{string(tailcfg.PeerCapabilityKubernetes): {`{"recorderAddrs":["[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80","100.99.99.99:80"]}`}}),
 | 
						|
			wantRecorderAddresses: []netip.AddrPort{addr1, addr2},
 | 
						|
			wantFailOpen:          true,
 | 
						|
		},
 | 
						|
		{
 | 
						|
			name:                  "odd_rule_combination_fail_closed",
 | 
						|
			who:                   whoResp(map[string][]string{string(tailcfg.PeerCapabilityKubernetes): {`{"recorderAddrs":["100.99.99.99:80"],"enforceRecorder":false}`, `{"recorderAddrs":["[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"]}`, `{"enforceRecorder":true,"impersonate":{"groups":["system:masters"]}}`}}),
 | 
						|
			wantRecorderAddresses: []netip.AddrPort{addr2, addr1},
 | 
						|
		},
 | 
						|
		{
 | 
						|
			name:         "no_caps",
 | 
						|
			who:          whoResp(map[string][]string{}),
 | 
						|
			wantFailOpen: true,
 | 
						|
		},
 | 
						|
		{
 | 
						|
			name:         "no_recorder_caps",
 | 
						|
			who:          whoResp(map[string][]string{"foo": {`{"x":"y"}`}, string(tailcfg.PeerCapabilityKubernetes): {`{"impersonate":{"groups":["system:masters"]}}`}}),
 | 
						|
			wantFailOpen: true,
 | 
						|
		},
 | 
						|
	}
 | 
						|
	for _, tt := range tests {
 | 
						|
		t.Run(tt.name, func(t *testing.T) {
 | 
						|
			gotFailOpen, gotRecorderAddresses, err := determineRecorderConfig(tt.who)
 | 
						|
			if err != nil {
 | 
						|
				t.Fatalf("unexpected error: %v", err)
 | 
						|
			}
 | 
						|
			if gotFailOpen != tt.wantFailOpen {
 | 
						|
				t.Errorf("determineRecorderConfig() gotFailOpen = %v, want %v", gotFailOpen, tt.wantFailOpen)
 | 
						|
			}
 | 
						|
			if !reflect.DeepEqual(gotRecorderAddresses, tt.wantRecorderAddresses) {
 | 
						|
				t.Errorf("determineRecorderConfig() gotRecorderAddresses = %v, want %v", gotRecorderAddresses, tt.wantRecorderAddresses)
 | 
						|
			}
 | 
						|
		})
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func whoResp(capMap map[string][]string) *apitype.WhoIsResponse {
 | 
						|
	resp := &apitype.WhoIsResponse{
 | 
						|
		CapMap: tailcfg.PeerCapMap{},
 | 
						|
	}
 | 
						|
	for cap, rules := range capMap {
 | 
						|
		resp.CapMap[tailcfg.PeerCapability(cap)] = raw(rules...)
 | 
						|
	}
 | 
						|
	return resp
 | 
						|
}
 | 
						|
 | 
						|
func raw(in ...string) []tailcfg.RawMessage {
 | 
						|
	var out []tailcfg.RawMessage
 | 
						|
	for _, i := range in {
 | 
						|
		out = append(out, tailcfg.RawMessage(i))
 | 
						|
	}
 | 
						|
	return out
 | 
						|
}
 |