mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 21:15:39 +00:00
ba517ab388
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
|
|
}
|