mirror of
https://github.com/tailscale/tailscale.git
synced 2025-09-21 13:41:46 +02:00
537 lines
15 KiB
Go
537 lines
15 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !plan9
|
|
|
|
package apiproxy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"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(ctx context.Context, 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"]}`),
|
|
},
|
|
},
|
|
}
|
|
|
|
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: json.RawMessage(`null`),
|
|
},
|
|
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: json.RawMessage(`null`),
|
|
},
|
|
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: json.RawMessage(`null`),
|
|
},
|
|
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: json.RawMessage(`null`),
|
|
},
|
|
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: json.RawMessage(`null`),
|
|
},
|
|
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: json.RawMessage(`null`),
|
|
},
|
|
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: json.RawMessage(`null`),
|
|
},
|
|
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: 2,
|
|
},
|
|
{
|
|
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"}`),
|
|
},
|
|
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: json.RawMessage(`null`),
|
|
},
|
|
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: json.RawMessage(`null`),
|
|
},
|
|
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: json.RawMessage(`null`),
|
|
},
|
|
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: json.RawMessage(`null`),
|
|
},
|
|
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 = time.Time{}
|
|
tt.wantEvent.Timestamp = time.Time{}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|