cmd/tsrecorder: adds sending api level logging to tsrecorder (#16960)

Updates #17141

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
This commit is contained in:
Tom Meadows
2025-10-08 15:15:12 +01:00
committed by GitHub
parent f25e47cdeb
commit cd2a3425cb
13 changed files with 1014 additions and 21 deletions
+91
View File
@@ -110,6 +110,97 @@ func supportsV2(ctx context.Context, hc *http.Client, ap netip.AddrPort) bool {
return resp.StatusCode == http.StatusOK && resp.ProtoMajor > 1
}
// supportsEvent checks whether a recorder instance supports the /v2/event
// endpoint.
func supportsEvent(ctx context.Context, hc *http.Client, ap netip.AddrPort) (bool, error) {
ctx, cancel := context.WithTimeout(ctx, http2ProbeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, httpm.HEAD, fmt.Sprintf("http://%s/v2/event", ap), nil)
if err != nil {
return false, err
}
resp, err := hc.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return true, nil
}
if resp.StatusCode != http.StatusNotFound {
body, err := io.ReadAll(resp.Body)
if err != nil {
// Handle the case where reading the body itself fails
return false, fmt.Errorf("server returned non-OK status: %s, and failed to read body: %w", resp.Status, err)
}
return false, fmt.Errorf("server returned non-OK status: %d: %s", resp.StatusCode, string(body))
}
return false, nil
}
const addressNotSupportEventv2 = `recorder at address %q does not support "/v2/event" endpoint`
type EventAPINotSupportedErr struct {
ap netip.AddrPort
}
func (e EventAPINotSupportedErr) Error() string {
return fmt.Sprintf(addressNotSupportEventv2, e.ap)
}
// SendEvent sends an event the tsrecorders /v2/event endpoint.
func SendEvent(ap netip.AddrPort, event io.Reader, dial netx.DialFunc) (retErr error) {
ctx, cancel := context.WithCancel(context.Background())
defer func() {
if retErr != nil {
cancel()
}
}()
client := clientHTTP1(ctx, dial)
supported, err := supportsEvent(ctx, client, ap)
if err != nil {
return fmt.Errorf("error checking support for `/v2/event` endpoint: %w", err)
}
if !supported {
return EventAPINotSupportedErr{
ap: ap,
}
}
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("http://%s/v2/event", ap.String()), event)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error sending request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
// Handle the case where reading the body itself fails
return fmt.Errorf("server returned non-OK status: %s, and failed to read body: %w", resp.Status, err)
}
return fmt.Errorf("server returned non-OK status: %d: %s", resp.StatusCode, string(body))
}
return nil
}
// connectV1 connects to the legacy /record endpoint on the recorder. It is
// used for backwards-compatibility with older tsrecorder instances.
//
+99 -3
View File
@@ -9,11 +9,13 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
@@ -148,9 +150,9 @@ func TestConnectToRecorder(t *testing.T) {
// Wire up h2c-compatible HTTP/2 server. This is optional
// because the v1 recorder didn't support HTTP/2 and we try to
// mimic that.
h2s := &http2.Server{}
srv.Config.Handler = h2c.NewHandler(mux, h2s)
if err := http2.ConfigureServer(srv.Config, h2s); err != nil {
s := &http2.Server{}
srv.Config.Handler = h2c.NewHandler(mux, s)
if err := http2.ConfigureServer(srv.Config, s); err != nil {
t.Errorf("configuring HTTP/2 support in server: %v", err)
}
}
@@ -187,3 +189,97 @@ func TestConnectToRecorder(t *testing.T) {
})
}
}
func TestSendEvent(t *testing.T) {
t.Run("supported", func(t *testing.T) {
eventBody := `{"foo":"bar"}`
eventRecieved := make(chan []byte, 1)
mux := http.NewServeMux()
mux.HandleFunc("HEAD /v2/event", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("POST /v2/event", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
}
eventRecieved <- body
w.WriteHeader(http.StatusOK)
})
srv := httptest.NewUnstartedServer(mux)
s := &http2.Server{}
srv.Config.Handler = h2c.NewHandler(mux, s)
if err := http2.ConfigureServer(srv.Config, s); err != nil {
t.Fatalf("configuring HTTP/2 support in server: %v", err)
}
srv.Start()
t.Cleanup(srv.Close)
d := new(net.Dialer)
addr := netip.MustParseAddrPort(srv.Listener.Addr().String())
err := SendEvent(addr, bytes.NewBufferString(eventBody), d.DialContext)
if err != nil {
t.Fatalf("SendEvent: %v", err)
}
if recv := string(<-eventRecieved); recv != eventBody {
t.Errorf("mismatch in event body, sent %q, received %q", eventBody, recv)
}
})
t.Run("not_supported", func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("HEAD /v2/event", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
srv := httptest.NewUnstartedServer(mux)
s := &http2.Server{}
srv.Config.Handler = h2c.NewHandler(mux, s)
if err := http2.ConfigureServer(srv.Config, s); err != nil {
t.Fatalf("configuring HTTP/2 support in server: %v", err)
}
srv.Start()
t.Cleanup(srv.Close)
d := new(net.Dialer)
addr := netip.MustParseAddrPort(srv.Listener.Addr().String())
err := SendEvent(addr, nil, d.DialContext)
if err == nil {
t.Fatal("expected an error, got nil")
}
if !strings.Contains(err.Error(), fmt.Sprintf(addressNotSupportEventv2, srv.Listener.Addr().String())) {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("server_error", func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("HEAD /v2/event", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("POST /v2/event", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
srv := httptest.NewUnstartedServer(mux)
s := &http2.Server{}
srv.Config.Handler = h2c.NewHandler(mux, s)
if err := http2.ConfigureServer(srv.Config, s); err != nil {
t.Fatalf("configuring HTTP/2 support in server: %v", err)
}
srv.Start()
t.Cleanup(srv.Close)
d := new(net.Dialer)
addr := netip.MustParseAddrPort(srv.Listener.Addr().String())
err := SendEvent(addr, nil, d.DialContext)
if err == nil {
t.Fatal("expected an error, got nil")
}
if !strings.Contains(err.Error(), "server returned non-OK status") {
t.Fatalf("unexpected error: %v", err)
}
})
}
+104
View File
@@ -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"`
}
-1
View File
@@ -62,7 +62,6 @@ type CastHeader struct {
ConnectionID string `json:"connectionID"`
// Fields that are only set for Kubernetes API server proxy session recordings:
Kubernetes *Kubernetes `json:"kubernetes,omitempty"`
}