cmd/tsrecorder: adds sending api level logging to tsrecorder (#16960)
Updates #17141 Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>main
parent
f25e47cdeb
commit
cd2a3425cb
@ -1 +1 @@ |
|||||||
sha256-jsmQ0S1Uh1cU/kr0onYLJY9VYcFx297QZjQALM3wX10= |
sha256-rV3C2Vi48FCifGt58OdEO4+Av0HRIs8sUJVvp/gEBLw= |
||||||
|
|||||||
@ -0,0 +1,548 @@ |
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !plan9
|
||||||
|
|
||||||
|
package apiproxy |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"encoding/json" |
||||||
|
"errors" |
||||||
|
"io" |
||||||
|
"net/http" |
||||||
|
"net/http/httptest" |
||||||
|
"net/netip" |
||||||
|
"net/url" |
||||||
|
"reflect" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"go.uber.org/zap" |
||||||
|
"tailscale.com/client/tailscale/apitype" |
||||||
|
"tailscale.com/net/netx" |
||||||
|
"tailscale.com/sessionrecording" |
||||||
|
"tailscale.com/tailcfg" |
||||||
|
"tailscale.com/tsnet" |
||||||
|
) |
||||||
|
|
||||||
|
type fakeSender struct { |
||||||
|
sent map[netip.AddrPort][]byte |
||||||
|
err error |
||||||
|
calls int |
||||||
|
} |
||||||
|
|
||||||
|
func (s *fakeSender) Send(ap netip.AddrPort, event io.Reader, dial netx.DialFunc) error { |
||||||
|
s.calls++ |
||||||
|
if s.err != nil { |
||||||
|
return s.err |
||||||
|
} |
||||||
|
if s.sent == nil { |
||||||
|
s.sent = make(map[netip.AddrPort][]byte) |
||||||
|
} |
||||||
|
data, _ := io.ReadAll(event) |
||||||
|
s.sent[ap] = data |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func (s *fakeSender) Reset() { |
||||||
|
s.sent = nil |
||||||
|
s.err = nil |
||||||
|
s.calls = 0 |
||||||
|
} |
||||||
|
|
||||||
|
func TestRecordRequestAsEvent(t *testing.T) { |
||||||
|
zl, err := zap.NewDevelopment() |
||||||
|
if err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
|
||||||
|
sender := &fakeSender{} |
||||||
|
ap := &APIServerProxy{ |
||||||
|
log: zl.Sugar(), |
||||||
|
ts: &tsnet.Server{}, |
||||||
|
sendEventFunc: sender.Send, |
||||||
|
} |
||||||
|
|
||||||
|
defaultWho := &apitype.WhoIsResponse{ |
||||||
|
Node: &tailcfg.Node{ |
||||||
|
StableID: "stable-id", |
||||||
|
Name: "node.ts.net.", |
||||||
|
}, |
||||||
|
UserProfile: &tailcfg.UserProfile{ |
||||||
|
ID: 1, |
||||||
|
LoginName: "user@example.com", |
||||||
|
}, |
||||||
|
CapMap: tailcfg.PeerCapMap{ |
||||||
|
tailcfg.PeerCapabilityKubernetes: []tailcfg.RawMessage{ |
||||||
|
tailcfg.RawMessage(`{"recorderAddrs":["127.0.0.1:1234"]}`), |
||||||
|
tailcfg.RawMessage(`{"enforceRecorder": true}`), |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
defaultSource := sessionrecording.Source{ |
||||||
|
Node: "node.ts.net", |
||||||
|
NodeID: "stable-id", |
||||||
|
NodeUser: "user@example.com", |
||||||
|
NodeUserID: 1, |
||||||
|
} |
||||||
|
|
||||||
|
tests := []struct { |
||||||
|
name string |
||||||
|
req func() *http.Request |
||||||
|
who *apitype.WhoIsResponse |
||||||
|
setupSender func() |
||||||
|
wantErr bool |
||||||
|
wantEvent *sessionrecording.Event |
||||||
|
wantNumCalls int |
||||||
|
}{ |
||||||
|
{ |
||||||
|
name: "request-with-dot-in-name", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/api/v1/namespaces/default/pods/foo.bar", nil) |
||||||
|
}, |
||||||
|
who: defaultWho, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 1, |
||||||
|
wantEvent: &sessionrecording.Event{ |
||||||
|
Type: sessionrecording.KubernetesAPIEventType, |
||||||
|
Request: sessionrecording.Request{ |
||||||
|
Method: "GET", |
||||||
|
Path: "/api/v1/namespaces/default/pods/foo.bar", |
||||||
|
Body: nil, |
||||||
|
QueryParameters: url.Values{}, |
||||||
|
}, |
||||||
|
Kubernetes: sessionrecording.KubernetesRequestInfo{ |
||||||
|
IsResourceRequest: true, |
||||||
|
Path: "/api/v1/namespaces/default/pods/foo.bar", |
||||||
|
Verb: "get", |
||||||
|
APIPrefix: "api", |
||||||
|
APIVersion: "v1", |
||||||
|
Namespace: "default", |
||||||
|
Resource: "pods", |
||||||
|
Name: "foo.bar", |
||||||
|
Parts: []string{"pods", "foo.bar"}, |
||||||
|
}, |
||||||
|
Source: defaultSource, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "request-with-dash-in-name", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/api/v1/namespaces/default/pods/foo-bar", nil) |
||||||
|
}, |
||||||
|
who: defaultWho, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 1, |
||||||
|
wantEvent: &sessionrecording.Event{ |
||||||
|
Type: sessionrecording.KubernetesAPIEventType, |
||||||
|
Request: sessionrecording.Request{ |
||||||
|
Method: "GET", |
||||||
|
Path: "/api/v1/namespaces/default/pods/foo-bar", |
||||||
|
Body: nil, |
||||||
|
QueryParameters: url.Values{}, |
||||||
|
}, |
||||||
|
Kubernetes: sessionrecording.KubernetesRequestInfo{ |
||||||
|
IsResourceRequest: true, |
||||||
|
Path: "/api/v1/namespaces/default/pods/foo-bar", |
||||||
|
Verb: "get", |
||||||
|
APIPrefix: "api", |
||||||
|
APIVersion: "v1", |
||||||
|
Namespace: "default", |
||||||
|
Resource: "pods", |
||||||
|
Name: "foo-bar", |
||||||
|
Parts: []string{"pods", "foo-bar"}, |
||||||
|
}, |
||||||
|
Source: defaultSource, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "request-with-query-parameter", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/api/v1/pods?watch=true", nil) |
||||||
|
}, |
||||||
|
who: defaultWho, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 1, |
||||||
|
wantEvent: &sessionrecording.Event{ |
||||||
|
Type: sessionrecording.KubernetesAPIEventType, |
||||||
|
Request: sessionrecording.Request{ |
||||||
|
Method: "GET", |
||||||
|
Path: "/api/v1/pods?watch=true", |
||||||
|
Body: nil, |
||||||
|
QueryParameters: url.Values{"watch": []string{"true"}}, |
||||||
|
}, |
||||||
|
Kubernetes: sessionrecording.KubernetesRequestInfo{ |
||||||
|
IsResourceRequest: true, |
||||||
|
Path: "/api/v1/pods", |
||||||
|
Verb: "watch", |
||||||
|
APIPrefix: "api", |
||||||
|
APIVersion: "v1", |
||||||
|
Resource: "pods", |
||||||
|
Parts: []string{"pods"}, |
||||||
|
}, |
||||||
|
Source: defaultSource, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "request-with-label-selector", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/api/v1/pods?labelSelector=app%3Dfoo", nil) |
||||||
|
}, |
||||||
|
who: defaultWho, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 1, |
||||||
|
wantEvent: &sessionrecording.Event{ |
||||||
|
Type: sessionrecording.KubernetesAPIEventType, |
||||||
|
Request: sessionrecording.Request{ |
||||||
|
Method: "GET", |
||||||
|
Path: "/api/v1/pods?labelSelector=app%3Dfoo", |
||||||
|
Body: nil, |
||||||
|
QueryParameters: url.Values{"labelSelector": []string{"app=foo"}}, |
||||||
|
}, |
||||||
|
Kubernetes: sessionrecording.KubernetesRequestInfo{ |
||||||
|
IsResourceRequest: true, |
||||||
|
Path: "/api/v1/pods", |
||||||
|
Verb: "list", |
||||||
|
APIPrefix: "api", |
||||||
|
APIVersion: "v1", |
||||||
|
Resource: "pods", |
||||||
|
Parts: []string{"pods"}, |
||||||
|
LabelSelector: "app=foo", |
||||||
|
}, |
||||||
|
Source: defaultSource, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "request-with-field-selector", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/api/v1/pods?fieldSelector=status.phase%3DRunning", nil) |
||||||
|
}, |
||||||
|
who: defaultWho, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 1, |
||||||
|
wantEvent: &sessionrecording.Event{ |
||||||
|
Type: sessionrecording.KubernetesAPIEventType, |
||||||
|
Request: sessionrecording.Request{ |
||||||
|
Method: "GET", |
||||||
|
Path: "/api/v1/pods?fieldSelector=status.phase%3DRunning", |
||||||
|
Body: nil, |
||||||
|
QueryParameters: url.Values{"fieldSelector": []string{"status.phase=Running"}}, |
||||||
|
}, |
||||||
|
Kubernetes: sessionrecording.KubernetesRequestInfo{ |
||||||
|
IsResourceRequest: true, |
||||||
|
Path: "/api/v1/pods", |
||||||
|
Verb: "list", |
||||||
|
APIPrefix: "api", |
||||||
|
APIVersion: "v1", |
||||||
|
Resource: "pods", |
||||||
|
Parts: []string{"pods"}, |
||||||
|
FieldSelector: "status.phase=Running", |
||||||
|
}, |
||||||
|
Source: defaultSource, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "request-for-non-existent-resource", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/api/v1/foo", nil) |
||||||
|
}, |
||||||
|
who: defaultWho, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 1, |
||||||
|
wantEvent: &sessionrecording.Event{ |
||||||
|
Type: sessionrecording.KubernetesAPIEventType, |
||||||
|
Request: sessionrecording.Request{ |
||||||
|
Method: "GET", |
||||||
|
Path: "/api/v1/foo", |
||||||
|
Body: nil, |
||||||
|
QueryParameters: url.Values{}, |
||||||
|
}, |
||||||
|
Kubernetes: sessionrecording.KubernetesRequestInfo{ |
||||||
|
IsResourceRequest: true, |
||||||
|
Path: "/api/v1/foo", |
||||||
|
Verb: "list", |
||||||
|
APIPrefix: "api", |
||||||
|
APIVersion: "v1", |
||||||
|
Resource: "foo", |
||||||
|
Parts: []string{"foo"}, |
||||||
|
}, |
||||||
|
Source: defaultSource, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "basic-request", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/api/v1/pods", nil) |
||||||
|
}, |
||||||
|
who: defaultWho, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 1, |
||||||
|
wantEvent: &sessionrecording.Event{ |
||||||
|
Type: sessionrecording.KubernetesAPIEventType, |
||||||
|
Request: sessionrecording.Request{ |
||||||
|
Method: "GET", |
||||||
|
Path: "/api/v1/pods", |
||||||
|
Body: nil, |
||||||
|
QueryParameters: url.Values{}, |
||||||
|
}, |
||||||
|
Kubernetes: sessionrecording.KubernetesRequestInfo{ |
||||||
|
IsResourceRequest: true, |
||||||
|
Path: "/api/v1/pods", |
||||||
|
Verb: "list", |
||||||
|
APIPrefix: "api", |
||||||
|
APIVersion: "v1", |
||||||
|
Resource: "pods", |
||||||
|
Parts: []string{"pods"}, |
||||||
|
}, |
||||||
|
Source: defaultSource, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "multiple-recorders", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/api/v1/pods", nil) |
||||||
|
}, |
||||||
|
who: &apitype.WhoIsResponse{ |
||||||
|
Node: defaultWho.Node, |
||||||
|
UserProfile: defaultWho.UserProfile, |
||||||
|
CapMap: tailcfg.PeerCapMap{ |
||||||
|
tailcfg.PeerCapabilityKubernetes: []tailcfg.RawMessage{ |
||||||
|
tailcfg.RawMessage(`{"recorderAddrs":["127.0.0.1:1234", "127.0.0.1:5678"]}`), |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 1, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "request-with-body", |
||||||
|
req: func() *http.Request { |
||||||
|
req := httptest.NewRequest("POST", "/api/v1/pods", bytes.NewBufferString(`{"foo":"bar"}`)) |
||||||
|
req.Header.Set("Content-Type", "application/json") |
||||||
|
return req |
||||||
|
}, |
||||||
|
who: defaultWho, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 1, |
||||||
|
wantEvent: &sessionrecording.Event{ |
||||||
|
Type: sessionrecording.KubernetesAPIEventType, |
||||||
|
Request: sessionrecording.Request{ |
||||||
|
Method: "POST", |
||||||
|
Path: "/api/v1/pods", |
||||||
|
Body: json.RawMessage(`{"foo":"bar"}`), |
||||||
|
QueryParameters: url.Values{}, |
||||||
|
}, |
||||||
|
Kubernetes: sessionrecording.KubernetesRequestInfo{ |
||||||
|
IsResourceRequest: true, |
||||||
|
Path: "/api/v1/pods", |
||||||
|
Verb: "create", |
||||||
|
APIPrefix: "api", |
||||||
|
APIVersion: "v1", |
||||||
|
Resource: "pods", |
||||||
|
Parts: []string{"pods"}, |
||||||
|
}, |
||||||
|
Source: defaultSource, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "tagged-node", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/api/v1/pods", nil) |
||||||
|
}, |
||||||
|
who: &apitype.WhoIsResponse{ |
||||||
|
Node: &tailcfg.Node{ |
||||||
|
StableID: "stable-id", |
||||||
|
Name: "node.ts.net.", |
||||||
|
Tags: []string{"tag:foo"}, |
||||||
|
}, |
||||||
|
UserProfile: &tailcfg.UserProfile{}, |
||||||
|
CapMap: defaultWho.CapMap, |
||||||
|
}, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 1, |
||||||
|
wantEvent: &sessionrecording.Event{ |
||||||
|
Type: sessionrecording.KubernetesAPIEventType, |
||||||
|
Request: sessionrecording.Request{ |
||||||
|
Method: "GET", |
||||||
|
Path: "/api/v1/pods", |
||||||
|
Body: nil, |
||||||
|
QueryParameters: url.Values{}, |
||||||
|
}, |
||||||
|
Kubernetes: sessionrecording.KubernetesRequestInfo{ |
||||||
|
IsResourceRequest: true, |
||||||
|
Path: "/api/v1/pods", |
||||||
|
Verb: "list", |
||||||
|
APIPrefix: "api", |
||||||
|
APIVersion: "v1", |
||||||
|
Resource: "pods", |
||||||
|
Parts: []string{"pods"}, |
||||||
|
}, |
||||||
|
Source: sessionrecording.Source{ |
||||||
|
Node: "node.ts.net", |
||||||
|
NodeID: "stable-id", |
||||||
|
NodeTags: []string{"tag:foo"}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "no-recorders", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/api/v1/pods", nil) |
||||||
|
}, |
||||||
|
who: &apitype.WhoIsResponse{ |
||||||
|
Node: defaultWho.Node, |
||||||
|
UserProfile: defaultWho.UserProfile, |
||||||
|
CapMap: tailcfg.PeerCapMap{}, |
||||||
|
}, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 0, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "error-sending", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/api/v1/pods", nil) |
||||||
|
}, |
||||||
|
who: defaultWho, |
||||||
|
setupSender: func() { |
||||||
|
sender.Reset() |
||||||
|
sender.err = errors.New("send error") |
||||||
|
}, |
||||||
|
wantErr: true, |
||||||
|
wantNumCalls: 1, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "request-for-crd", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/apis/custom.example.com/v1/myresources", nil) |
||||||
|
}, |
||||||
|
who: defaultWho, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 1, |
||||||
|
wantEvent: &sessionrecording.Event{ |
||||||
|
Type: sessionrecording.KubernetesAPIEventType, |
||||||
|
Request: sessionrecording.Request{ |
||||||
|
Method: "GET", |
||||||
|
Path: "/apis/custom.example.com/v1/myresources", |
||||||
|
Body: nil, |
||||||
|
QueryParameters: url.Values{}, |
||||||
|
}, |
||||||
|
Kubernetes: sessionrecording.KubernetesRequestInfo{ |
||||||
|
IsResourceRequest: true, |
||||||
|
Path: "/apis/custom.example.com/v1/myresources", |
||||||
|
Verb: "list", |
||||||
|
APIPrefix: "apis", |
||||||
|
APIGroup: "custom.example.com", |
||||||
|
APIVersion: "v1", |
||||||
|
Resource: "myresources", |
||||||
|
Parts: []string{"myresources"}, |
||||||
|
}, |
||||||
|
Source: defaultSource, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "request-with-proxy-verb", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/api/v1/namespaces/default/pods/foo/proxy", nil) |
||||||
|
}, |
||||||
|
who: defaultWho, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 1, |
||||||
|
wantEvent: &sessionrecording.Event{ |
||||||
|
Type: sessionrecording.KubernetesAPIEventType, |
||||||
|
Request: sessionrecording.Request{ |
||||||
|
Method: "GET", |
||||||
|
Path: "/api/v1/namespaces/default/pods/foo/proxy", |
||||||
|
Body: nil, |
||||||
|
QueryParameters: url.Values{}, |
||||||
|
}, |
||||||
|
Kubernetes: sessionrecording.KubernetesRequestInfo{ |
||||||
|
IsResourceRequest: true, |
||||||
|
Path: "/api/v1/namespaces/default/pods/foo/proxy", |
||||||
|
Verb: "get", |
||||||
|
APIPrefix: "api", |
||||||
|
APIVersion: "v1", |
||||||
|
Namespace: "default", |
||||||
|
Resource: "pods", |
||||||
|
Subresource: "proxy", |
||||||
|
Name: "foo", |
||||||
|
Parts: []string{"pods", "foo", "proxy"}, |
||||||
|
}, |
||||||
|
Source: defaultSource, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "request-with-complex-path", |
||||||
|
req: func() *http.Request { |
||||||
|
return httptest.NewRequest("GET", "/api/v1/namespaces/default/services/foo:8080/proxy-subpath/more/segments", nil) |
||||||
|
}, |
||||||
|
who: defaultWho, |
||||||
|
setupSender: func() { sender.Reset() }, |
||||||
|
wantNumCalls: 1, |
||||||
|
wantEvent: &sessionrecording.Event{ |
||||||
|
Type: sessionrecording.KubernetesAPIEventType, |
||||||
|
Request: sessionrecording.Request{ |
||||||
|
Method: "GET", |
||||||
|
Path: "/api/v1/namespaces/default/services/foo:8080/proxy-subpath/more/segments", |
||||||
|
Body: nil, |
||||||
|
QueryParameters: url.Values{}, |
||||||
|
}, |
||||||
|
Kubernetes: sessionrecording.KubernetesRequestInfo{ |
||||||
|
IsResourceRequest: true, |
||||||
|
Path: "/api/v1/namespaces/default/services/foo:8080/proxy-subpath/more/segments", |
||||||
|
Verb: "get", |
||||||
|
APIPrefix: "api", |
||||||
|
APIVersion: "v1", |
||||||
|
Namespace: "default", |
||||||
|
Resource: "services", |
||||||
|
Subresource: "proxy-subpath", |
||||||
|
Name: "foo:8080", |
||||||
|
Parts: []string{"services", "foo:8080", "proxy-subpath", "more", "segments"}, |
||||||
|
}, |
||||||
|
Source: defaultSource, |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for _, tt := range tests { |
||||||
|
t.Run(tt.name, func(t *testing.T) { |
||||||
|
tt.setupSender() |
||||||
|
|
||||||
|
req := tt.req() |
||||||
|
err := ap.recordRequestAsEvent(req, tt.who) |
||||||
|
|
||||||
|
if (err != nil) != tt.wantErr { |
||||||
|
t.Fatalf("recordRequestAsEvent() error = %v, wantErr %v", err, tt.wantErr) |
||||||
|
} |
||||||
|
|
||||||
|
if sender.calls != tt.wantNumCalls { |
||||||
|
t.Fatalf("expected %d calls to sender, got %d", tt.wantNumCalls, sender.calls) |
||||||
|
} |
||||||
|
|
||||||
|
if tt.wantEvent != nil { |
||||||
|
for _, sentData := range sender.sent { |
||||||
|
var got sessionrecording.Event |
||||||
|
if err := json.Unmarshal(sentData, &got); err != nil { |
||||||
|
t.Fatalf("failed to unmarshal sent event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
got.Timestamp = 0 |
||||||
|
tt.wantEvent.Timestamp = got.Timestamp |
||||||
|
|
||||||
|
got.UserAgent = "" |
||||||
|
tt.wantEvent.UserAgent = "" |
||||||
|
|
||||||
|
if !bytes.Equal(got.Request.Body, tt.wantEvent.Request.Body) { |
||||||
|
t.Errorf("sent event body does not match wanted event body.\nGot: %s\nWant: %s", string(got.Request.Body), string(tt.wantEvent.Request.Body)) |
||||||
|
} |
||||||
|
got.Request.Body = nil |
||||||
|
tt.wantEvent.Request.Body = nil |
||||||
|
|
||||||
|
if !reflect.DeepEqual(&got, tt.wantEvent) { |
||||||
|
t.Errorf("sent event does not match wanted event.\nGot: %#v\nWant: %#v", &got, tt.wantEvent) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,104 @@ |
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package sessionrecording |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/url" |
||||||
|
|
||||||
|
"tailscale.com/tailcfg" |
||||||
|
) |
||||||
|
|
||||||
|
const ( |
||||||
|
KubernetesAPIEventType = "kubernetes-api-request" |
||||||
|
) |
||||||
|
|
||||||
|
// Event represents the top-level structure of a tsrecorder event.
|
||||||
|
type Event struct { |
||||||
|
// Type specifies the kind of event being recorded (e.g., "kubernetes-api-request").
|
||||||
|
Type string `json:"type"` |
||||||
|
|
||||||
|
// ID is a reference of the path that this event is stored at in tsrecorder
|
||||||
|
ID string `json:"id"` |
||||||
|
|
||||||
|
// Timestamp is the time when the event was recorded represented as a unix timestamp.
|
||||||
|
Timestamp int64 `json:"timestamp"` |
||||||
|
|
||||||
|
// UserAgent is the UerAgent specified in the request, which helps identify
|
||||||
|
// the client software that initiated the request.
|
||||||
|
UserAgent string `json:"userAgent"` |
||||||
|
|
||||||
|
// Request holds details of the HTTP request.
|
||||||
|
Request Request `json:"request"` |
||||||
|
|
||||||
|
// Kubernetes contains Kubernetes-specific information about the request (if
|
||||||
|
// the type is `kubernetes-api-request`)
|
||||||
|
Kubernetes KubernetesRequestInfo `json:"kubernetes"` |
||||||
|
|
||||||
|
// Source provides details about the client that initiated the request.
|
||||||
|
Source Source `json:"source"` |
||||||
|
} |
||||||
|
|
||||||
|
// copied from https://github.com/kubernetes/kubernetes/blob/11ade2f7dd264c2f52a4a1342458abbbaa3cb2b1/staging/src/k8s.io/apiserver/pkg/endpoints/request/requestinfo.go#L44
|
||||||
|
// KubernetesRequestInfo contains Kubernetes specific information in the request (if the type is `kubernetes-api-request`)
|
||||||
|
type KubernetesRequestInfo struct { |
||||||
|
// IsResourceRequest indicates whether or not the request is for an API resource or subresource
|
||||||
|
IsResourceRequest bool |
||||||
|
// Path is the URL path of the request
|
||||||
|
Path string |
||||||
|
// Verb is the kube verb associated with the request for API requests, not the http verb. This includes things like list and watch.
|
||||||
|
// for non-resource requests, this is the lowercase http verb
|
||||||
|
Verb string |
||||||
|
|
||||||
|
APIPrefix string |
||||||
|
APIGroup string |
||||||
|
APIVersion string |
||||||
|
|
||||||
|
Namespace string |
||||||
|
// Resource is the name of the resource being requested. This is not the kind. For example: pods
|
||||||
|
Resource string |
||||||
|
// Subresource is the name of the subresource being requested. This is a different resource, scoped to the parent resource, but it may have a different kind.
|
||||||
|
// For instance, /pods has the resource "pods" and the kind "Pod", while /pods/foo/status has the resource "pods", the sub resource "status", and the kind "Pod"
|
||||||
|
// (because status operates on pods). The binding resource for a pod though may be /pods/foo/binding, which has resource "pods", subresource "binding", and kind "Binding".
|
||||||
|
Subresource string |
||||||
|
// Name is empty for some verbs, but if the request directly indicates a name (not in body content) then this field is filled in.
|
||||||
|
Name string |
||||||
|
// Parts are the path parts for the request, always starting with /{resource}/{name}
|
||||||
|
Parts []string |
||||||
|
|
||||||
|
// FieldSelector contains the unparsed field selector from a request. It is only present if the apiserver
|
||||||
|
// honors field selectors for the verb this request is associated with.
|
||||||
|
FieldSelector string |
||||||
|
// LabelSelector contains the unparsed field selector from a request. It is only present if the apiserver
|
||||||
|
// honors field selectors for the verb this request is associated with.
|
||||||
|
LabelSelector string |
||||||
|
} |
||||||
|
|
||||||
|
type Source struct { |
||||||
|
// Node is the FQDN of the node originating the connection.
|
||||||
|
// It is also the MagicDNS name for the node.
|
||||||
|
// It does not have a trailing dot.
|
||||||
|
// e.g. "host.tail-scale.ts.net"
|
||||||
|
Node string `json:"node"` |
||||||
|
|
||||||
|
// NodeID is the node ID of the node originating the connection.
|
||||||
|
NodeID tailcfg.StableNodeID `json:"nodeID"` |
||||||
|
|
||||||
|
// Tailscale-specific fields:
|
||||||
|
// NodeTags is the list of tags on the node originating the connection (if any).
|
||||||
|
NodeTags []string `json:"nodeTags,omitempty"` |
||||||
|
|
||||||
|
// NodeUserID is the user ID of the node originating the connection (if not tagged).
|
||||||
|
NodeUserID tailcfg.UserID `json:"nodeUserID,omitempty"` // if not tagged
|
||||||
|
|
||||||
|
// NodeUser is the LoginName of the node originating the connection (if not tagged).
|
||||||
|
NodeUser string `json:"nodeUser,omitempty"` |
||||||
|
} |
||||||
|
|
||||||
|
// Request holds information about a request.
|
||||||
|
type Request struct { |
||||||
|
Method string `json:"method"` |
||||||
|
Path string `json:"path"` |
||||||
|
Body []byte `json:"body"` |
||||||
|
QueryParameters url.Values `json:"queryParameters"` |
||||||
|
} |
||||||
Loading…
Reference in new issue