| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549 |
- // 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,
- eventsEnabled: true,
- }
- defaultWho := &apitype.WhoIsResponse{
- Node: &tailcfg.Node{
- StableID: "stable-id",
- Name: "node.ts.net.",
- },
- UserProfile: &tailcfg.UserProfile{
- ID: 1,
- LoginName: "[email protected]",
- },
- 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: "[email protected]",
- 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)
- }
- }
- }
- })
- }
- }
|