proxy_events_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. // Copyright (c) Tailscale Inc & AUTHORS
  2. // SPDX-License-Identifier: BSD-3-Clause
  3. //go:build !plan9
  4. package apiproxy
  5. import (
  6. "bytes"
  7. "encoding/json"
  8. "errors"
  9. "io"
  10. "net/http"
  11. "net/http/httptest"
  12. "net/netip"
  13. "net/url"
  14. "reflect"
  15. "testing"
  16. "go.uber.org/zap"
  17. "tailscale.com/client/tailscale/apitype"
  18. "tailscale.com/net/netx"
  19. "tailscale.com/sessionrecording"
  20. "tailscale.com/tailcfg"
  21. "tailscale.com/tsnet"
  22. )
  23. type fakeSender struct {
  24. sent map[netip.AddrPort][]byte
  25. err error
  26. calls int
  27. }
  28. func (s *fakeSender) Send(ap netip.AddrPort, event io.Reader, dial netx.DialFunc) error {
  29. s.calls++
  30. if s.err != nil {
  31. return s.err
  32. }
  33. if s.sent == nil {
  34. s.sent = make(map[netip.AddrPort][]byte)
  35. }
  36. data, _ := io.ReadAll(event)
  37. s.sent[ap] = data
  38. return nil
  39. }
  40. func (s *fakeSender) Reset() {
  41. s.sent = nil
  42. s.err = nil
  43. s.calls = 0
  44. }
  45. func TestRecordRequestAsEvent(t *testing.T) {
  46. zl, err := zap.NewDevelopment()
  47. if err != nil {
  48. t.Fatal(err)
  49. }
  50. sender := &fakeSender{}
  51. ap := &APIServerProxy{
  52. log: zl.Sugar(),
  53. ts: &tsnet.Server{},
  54. sendEventFunc: sender.Send,
  55. eventsEnabled: true,
  56. }
  57. defaultWho := &apitype.WhoIsResponse{
  58. Node: &tailcfg.Node{
  59. StableID: "stable-id",
  60. Name: "node.ts.net.",
  61. },
  62. UserProfile: &tailcfg.UserProfile{
  63. ID: 1,
  64. LoginName: "[email protected]",
  65. },
  66. CapMap: tailcfg.PeerCapMap{
  67. tailcfg.PeerCapabilityKubernetes: []tailcfg.RawMessage{
  68. tailcfg.RawMessage(`{"recorderAddrs":["127.0.0.1:1234"]}`),
  69. tailcfg.RawMessage(`{"enforceRecorder": true}`),
  70. },
  71. },
  72. }
  73. defaultSource := sessionrecording.Source{
  74. Node: "node.ts.net",
  75. NodeID: "stable-id",
  76. NodeUser: "[email protected]",
  77. NodeUserID: 1,
  78. }
  79. tests := []struct {
  80. name string
  81. req func() *http.Request
  82. who *apitype.WhoIsResponse
  83. setupSender func()
  84. wantErr bool
  85. wantEvent *sessionrecording.Event
  86. wantNumCalls int
  87. }{
  88. {
  89. name: "request-with-dot-in-name",
  90. req: func() *http.Request {
  91. return httptest.NewRequest("GET", "/api/v1/namespaces/default/pods/foo.bar", nil)
  92. },
  93. who: defaultWho,
  94. setupSender: func() { sender.Reset() },
  95. wantNumCalls: 1,
  96. wantEvent: &sessionrecording.Event{
  97. Type: sessionrecording.KubernetesAPIEventType,
  98. Request: sessionrecording.Request{
  99. Method: "GET",
  100. Path: "/api/v1/namespaces/default/pods/foo.bar",
  101. Body: nil,
  102. QueryParameters: url.Values{},
  103. },
  104. Kubernetes: sessionrecording.KubernetesRequestInfo{
  105. IsResourceRequest: true,
  106. Path: "/api/v1/namespaces/default/pods/foo.bar",
  107. Verb: "get",
  108. APIPrefix: "api",
  109. APIVersion: "v1",
  110. Namespace: "default",
  111. Resource: "pods",
  112. Name: "foo.bar",
  113. Parts: []string{"pods", "foo.bar"},
  114. },
  115. Source: defaultSource,
  116. },
  117. },
  118. {
  119. name: "request-with-dash-in-name",
  120. req: func() *http.Request {
  121. return httptest.NewRequest("GET", "/api/v1/namespaces/default/pods/foo-bar", nil)
  122. },
  123. who: defaultWho,
  124. setupSender: func() { sender.Reset() },
  125. wantNumCalls: 1,
  126. wantEvent: &sessionrecording.Event{
  127. Type: sessionrecording.KubernetesAPIEventType,
  128. Request: sessionrecording.Request{
  129. Method: "GET",
  130. Path: "/api/v1/namespaces/default/pods/foo-bar",
  131. Body: nil,
  132. QueryParameters: url.Values{},
  133. },
  134. Kubernetes: sessionrecording.KubernetesRequestInfo{
  135. IsResourceRequest: true,
  136. Path: "/api/v1/namespaces/default/pods/foo-bar",
  137. Verb: "get",
  138. APIPrefix: "api",
  139. APIVersion: "v1",
  140. Namespace: "default",
  141. Resource: "pods",
  142. Name: "foo-bar",
  143. Parts: []string{"pods", "foo-bar"},
  144. },
  145. Source: defaultSource,
  146. },
  147. },
  148. {
  149. name: "request-with-query-parameter",
  150. req: func() *http.Request {
  151. return httptest.NewRequest("GET", "/api/v1/pods?watch=true", nil)
  152. },
  153. who: defaultWho,
  154. setupSender: func() { sender.Reset() },
  155. wantNumCalls: 1,
  156. wantEvent: &sessionrecording.Event{
  157. Type: sessionrecording.KubernetesAPIEventType,
  158. Request: sessionrecording.Request{
  159. Method: "GET",
  160. Path: "/api/v1/pods?watch=true",
  161. Body: nil,
  162. QueryParameters: url.Values{"watch": []string{"true"}},
  163. },
  164. Kubernetes: sessionrecording.KubernetesRequestInfo{
  165. IsResourceRequest: true,
  166. Path: "/api/v1/pods",
  167. Verb: "watch",
  168. APIPrefix: "api",
  169. APIVersion: "v1",
  170. Resource: "pods",
  171. Parts: []string{"pods"},
  172. },
  173. Source: defaultSource,
  174. },
  175. },
  176. {
  177. name: "request-with-label-selector",
  178. req: func() *http.Request {
  179. return httptest.NewRequest("GET", "/api/v1/pods?labelSelector=app%3Dfoo", nil)
  180. },
  181. who: defaultWho,
  182. setupSender: func() { sender.Reset() },
  183. wantNumCalls: 1,
  184. wantEvent: &sessionrecording.Event{
  185. Type: sessionrecording.KubernetesAPIEventType,
  186. Request: sessionrecording.Request{
  187. Method: "GET",
  188. Path: "/api/v1/pods?labelSelector=app%3Dfoo",
  189. Body: nil,
  190. QueryParameters: url.Values{"labelSelector": []string{"app=foo"}},
  191. },
  192. Kubernetes: sessionrecording.KubernetesRequestInfo{
  193. IsResourceRequest: true,
  194. Path: "/api/v1/pods",
  195. Verb: "list",
  196. APIPrefix: "api",
  197. APIVersion: "v1",
  198. Resource: "pods",
  199. Parts: []string{"pods"},
  200. LabelSelector: "app=foo",
  201. },
  202. Source: defaultSource,
  203. },
  204. },
  205. {
  206. name: "request-with-field-selector",
  207. req: func() *http.Request {
  208. return httptest.NewRequest("GET", "/api/v1/pods?fieldSelector=status.phase%3DRunning", nil)
  209. },
  210. who: defaultWho,
  211. setupSender: func() { sender.Reset() },
  212. wantNumCalls: 1,
  213. wantEvent: &sessionrecording.Event{
  214. Type: sessionrecording.KubernetesAPIEventType,
  215. Request: sessionrecording.Request{
  216. Method: "GET",
  217. Path: "/api/v1/pods?fieldSelector=status.phase%3DRunning",
  218. Body: nil,
  219. QueryParameters: url.Values{"fieldSelector": []string{"status.phase=Running"}},
  220. },
  221. Kubernetes: sessionrecording.KubernetesRequestInfo{
  222. IsResourceRequest: true,
  223. Path: "/api/v1/pods",
  224. Verb: "list",
  225. APIPrefix: "api",
  226. APIVersion: "v1",
  227. Resource: "pods",
  228. Parts: []string{"pods"},
  229. FieldSelector: "status.phase=Running",
  230. },
  231. Source: defaultSource,
  232. },
  233. },
  234. {
  235. name: "request-for-non-existent-resource",
  236. req: func() *http.Request {
  237. return httptest.NewRequest("GET", "/api/v1/foo", nil)
  238. },
  239. who: defaultWho,
  240. setupSender: func() { sender.Reset() },
  241. wantNumCalls: 1,
  242. wantEvent: &sessionrecording.Event{
  243. Type: sessionrecording.KubernetesAPIEventType,
  244. Request: sessionrecording.Request{
  245. Method: "GET",
  246. Path: "/api/v1/foo",
  247. Body: nil,
  248. QueryParameters: url.Values{},
  249. },
  250. Kubernetes: sessionrecording.KubernetesRequestInfo{
  251. IsResourceRequest: true,
  252. Path: "/api/v1/foo",
  253. Verb: "list",
  254. APIPrefix: "api",
  255. APIVersion: "v1",
  256. Resource: "foo",
  257. Parts: []string{"foo"},
  258. },
  259. Source: defaultSource,
  260. },
  261. },
  262. {
  263. name: "basic-request",
  264. req: func() *http.Request {
  265. return httptest.NewRequest("GET", "/api/v1/pods", nil)
  266. },
  267. who: defaultWho,
  268. setupSender: func() { sender.Reset() },
  269. wantNumCalls: 1,
  270. wantEvent: &sessionrecording.Event{
  271. Type: sessionrecording.KubernetesAPIEventType,
  272. Request: sessionrecording.Request{
  273. Method: "GET",
  274. Path: "/api/v1/pods",
  275. Body: nil,
  276. QueryParameters: url.Values{},
  277. },
  278. Kubernetes: sessionrecording.KubernetesRequestInfo{
  279. IsResourceRequest: true,
  280. Path: "/api/v1/pods",
  281. Verb: "list",
  282. APIPrefix: "api",
  283. APIVersion: "v1",
  284. Resource: "pods",
  285. Parts: []string{"pods"},
  286. },
  287. Source: defaultSource,
  288. },
  289. },
  290. {
  291. name: "multiple-recorders",
  292. req: func() *http.Request {
  293. return httptest.NewRequest("GET", "/api/v1/pods", nil)
  294. },
  295. who: &apitype.WhoIsResponse{
  296. Node: defaultWho.Node,
  297. UserProfile: defaultWho.UserProfile,
  298. CapMap: tailcfg.PeerCapMap{
  299. tailcfg.PeerCapabilityKubernetes: []tailcfg.RawMessage{
  300. tailcfg.RawMessage(`{"recorderAddrs":["127.0.0.1:1234", "127.0.0.1:5678"]}`),
  301. },
  302. },
  303. },
  304. setupSender: func() { sender.Reset() },
  305. wantNumCalls: 1,
  306. },
  307. {
  308. name: "request-with-body",
  309. req: func() *http.Request {
  310. req := httptest.NewRequest("POST", "/api/v1/pods", bytes.NewBufferString(`{"foo":"bar"}`))
  311. req.Header.Set("Content-Type", "application/json")
  312. return req
  313. },
  314. who: defaultWho,
  315. setupSender: func() { sender.Reset() },
  316. wantNumCalls: 1,
  317. wantEvent: &sessionrecording.Event{
  318. Type: sessionrecording.KubernetesAPIEventType,
  319. Request: sessionrecording.Request{
  320. Method: "POST",
  321. Path: "/api/v1/pods",
  322. Body: json.RawMessage(`{"foo":"bar"}`),
  323. QueryParameters: url.Values{},
  324. },
  325. Kubernetes: sessionrecording.KubernetesRequestInfo{
  326. IsResourceRequest: true,
  327. Path: "/api/v1/pods",
  328. Verb: "create",
  329. APIPrefix: "api",
  330. APIVersion: "v1",
  331. Resource: "pods",
  332. Parts: []string{"pods"},
  333. },
  334. Source: defaultSource,
  335. },
  336. },
  337. {
  338. name: "tagged-node",
  339. req: func() *http.Request {
  340. return httptest.NewRequest("GET", "/api/v1/pods", nil)
  341. },
  342. who: &apitype.WhoIsResponse{
  343. Node: &tailcfg.Node{
  344. StableID: "stable-id",
  345. Name: "node.ts.net.",
  346. Tags: []string{"tag:foo"},
  347. },
  348. UserProfile: &tailcfg.UserProfile{},
  349. CapMap: defaultWho.CapMap,
  350. },
  351. setupSender: func() { sender.Reset() },
  352. wantNumCalls: 1,
  353. wantEvent: &sessionrecording.Event{
  354. Type: sessionrecording.KubernetesAPIEventType,
  355. Request: sessionrecording.Request{
  356. Method: "GET",
  357. Path: "/api/v1/pods",
  358. Body: nil,
  359. QueryParameters: url.Values{},
  360. },
  361. Kubernetes: sessionrecording.KubernetesRequestInfo{
  362. IsResourceRequest: true,
  363. Path: "/api/v1/pods",
  364. Verb: "list",
  365. APIPrefix: "api",
  366. APIVersion: "v1",
  367. Resource: "pods",
  368. Parts: []string{"pods"},
  369. },
  370. Source: sessionrecording.Source{
  371. Node: "node.ts.net",
  372. NodeID: "stable-id",
  373. NodeTags: []string{"tag:foo"},
  374. },
  375. },
  376. },
  377. {
  378. name: "no-recorders",
  379. req: func() *http.Request {
  380. return httptest.NewRequest("GET", "/api/v1/pods", nil)
  381. },
  382. who: &apitype.WhoIsResponse{
  383. Node: defaultWho.Node,
  384. UserProfile: defaultWho.UserProfile,
  385. CapMap: tailcfg.PeerCapMap{},
  386. },
  387. setupSender: func() { sender.Reset() },
  388. wantNumCalls: 0,
  389. },
  390. {
  391. name: "error-sending",
  392. req: func() *http.Request {
  393. return httptest.NewRequest("GET", "/api/v1/pods", nil)
  394. },
  395. who: defaultWho,
  396. setupSender: func() {
  397. sender.Reset()
  398. sender.err = errors.New("send error")
  399. },
  400. wantErr: true,
  401. wantNumCalls: 1,
  402. },
  403. {
  404. name: "request-for-crd",
  405. req: func() *http.Request {
  406. return httptest.NewRequest("GET", "/apis/custom.example.com/v1/myresources", nil)
  407. },
  408. who: defaultWho,
  409. setupSender: func() { sender.Reset() },
  410. wantNumCalls: 1,
  411. wantEvent: &sessionrecording.Event{
  412. Type: sessionrecording.KubernetesAPIEventType,
  413. Request: sessionrecording.Request{
  414. Method: "GET",
  415. Path: "/apis/custom.example.com/v1/myresources",
  416. Body: nil,
  417. QueryParameters: url.Values{},
  418. },
  419. Kubernetes: sessionrecording.KubernetesRequestInfo{
  420. IsResourceRequest: true,
  421. Path: "/apis/custom.example.com/v1/myresources",
  422. Verb: "list",
  423. APIPrefix: "apis",
  424. APIGroup: "custom.example.com",
  425. APIVersion: "v1",
  426. Resource: "myresources",
  427. Parts: []string{"myresources"},
  428. },
  429. Source: defaultSource,
  430. },
  431. },
  432. {
  433. name: "request-with-proxy-verb",
  434. req: func() *http.Request {
  435. return httptest.NewRequest("GET", "/api/v1/namespaces/default/pods/foo/proxy", nil)
  436. },
  437. who: defaultWho,
  438. setupSender: func() { sender.Reset() },
  439. wantNumCalls: 1,
  440. wantEvent: &sessionrecording.Event{
  441. Type: sessionrecording.KubernetesAPIEventType,
  442. Request: sessionrecording.Request{
  443. Method: "GET",
  444. Path: "/api/v1/namespaces/default/pods/foo/proxy",
  445. Body: nil,
  446. QueryParameters: url.Values{},
  447. },
  448. Kubernetes: sessionrecording.KubernetesRequestInfo{
  449. IsResourceRequest: true,
  450. Path: "/api/v1/namespaces/default/pods/foo/proxy",
  451. Verb: "get",
  452. APIPrefix: "api",
  453. APIVersion: "v1",
  454. Namespace: "default",
  455. Resource: "pods",
  456. Subresource: "proxy",
  457. Name: "foo",
  458. Parts: []string{"pods", "foo", "proxy"},
  459. },
  460. Source: defaultSource,
  461. },
  462. },
  463. {
  464. name: "request-with-complex-path",
  465. req: func() *http.Request {
  466. return httptest.NewRequest("GET", "/api/v1/namespaces/default/services/foo:8080/proxy-subpath/more/segments", nil)
  467. },
  468. who: defaultWho,
  469. setupSender: func() { sender.Reset() },
  470. wantNumCalls: 1,
  471. wantEvent: &sessionrecording.Event{
  472. Type: sessionrecording.KubernetesAPIEventType,
  473. Request: sessionrecording.Request{
  474. Method: "GET",
  475. Path: "/api/v1/namespaces/default/services/foo:8080/proxy-subpath/more/segments",
  476. Body: nil,
  477. QueryParameters: url.Values{},
  478. },
  479. Kubernetes: sessionrecording.KubernetesRequestInfo{
  480. IsResourceRequest: true,
  481. Path: "/api/v1/namespaces/default/services/foo:8080/proxy-subpath/more/segments",
  482. Verb: "get",
  483. APIPrefix: "api",
  484. APIVersion: "v1",
  485. Namespace: "default",
  486. Resource: "services",
  487. Subresource: "proxy-subpath",
  488. Name: "foo:8080",
  489. Parts: []string{"services", "foo:8080", "proxy-subpath", "more", "segments"},
  490. },
  491. Source: defaultSource,
  492. },
  493. },
  494. }
  495. for _, tt := range tests {
  496. t.Run(tt.name, func(t *testing.T) {
  497. tt.setupSender()
  498. req := tt.req()
  499. err := ap.recordRequestAsEvent(req, tt.who)
  500. if (err != nil) != tt.wantErr {
  501. t.Fatalf("recordRequestAsEvent() error = %v, wantErr %v", err, tt.wantErr)
  502. }
  503. if sender.calls != tt.wantNumCalls {
  504. t.Fatalf("expected %d calls to sender, got %d", tt.wantNumCalls, sender.calls)
  505. }
  506. if tt.wantEvent != nil {
  507. for _, sentData := range sender.sent {
  508. var got sessionrecording.Event
  509. if err := json.Unmarshal(sentData, &got); err != nil {
  510. t.Fatalf("failed to unmarshal sent event: %v", err)
  511. }
  512. got.Timestamp = 0
  513. tt.wantEvent.Timestamp = got.Timestamp
  514. got.UserAgent = ""
  515. tt.wantEvent.UserAgent = ""
  516. if !bytes.Equal(got.Request.Body, tt.wantEvent.Request.Body) {
  517. t.Errorf("sent event body does not match wanted event body.\nGot: %s\nWant: %s", string(got.Request.Body), string(tt.wantEvent.Request.Body))
  518. }
  519. got.Request.Body = nil
  520. tt.wantEvent.Request.Body = nil
  521. if !reflect.DeepEqual(&got, tt.wantEvent) {
  522. t.Errorf("sent event does not match wanted event.\nGot: %#v\nWant: %#v", &got, tt.wantEvent)
  523. }
  524. }
  525. }
  526. })
  527. }
  528. }