mirror of
				https://github.com/tailscale/tailscale.git
				synced 2025-10-30 00:29:48 +00:00 
			
		
		
		
	 a15ff1bade
			
		
	
	a15ff1bade
	
	
	
		
			
			cmd/k8s-operator,k8s-operator/sessionrecording: support recording WebSocket sessions Kubernetes currently supports two streaming protocols, SPDY and WebSockets. WebSockets are replacing SPDY, see https://github.com/kubernetes/enhancements/issues/4006. We were currently only supporting SPDY, erroring out if session was not SPDY and relying on the kube's built-in SPDY fallback. This PR: - adds support for parsing contents of 'kubectl exec' sessions streamed over WebSockets - adds logic to distinguish 'kubectl exec' requests for a SPDY/WebSockets sessions and call the relevant handler Updates tailscale/corp#19821 Signed-off-by: Irbe Krumina <irbe@tailscale.com> Co-authored-by: Tom Proctor <tomhjp@users.noreply.github.com>
		
			
				
	
	
		
			331 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			331 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // Copyright (c) Tailscale Inc & AUTHORS
 | |
| // SPDX-License-Identifier: BSD-3-Clause
 | |
| 
 | |
| //go:build !plan9
 | |
| 
 | |
| package spdy
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"compress/zlib"
 | |
| 	"encoding/binary"
 | |
| 	"fmt"
 | |
| 	"io"
 | |
| 	"net/http"
 | |
| 	"reflect"
 | |
| 	"strings"
 | |
| 	"testing"
 | |
| 	"time"
 | |
| 
 | |
| 	"math/rand"
 | |
| 
 | |
| 	"github.com/google/go-cmp/cmp"
 | |
| 	"go.uber.org/zap"
 | |
| )
 | |
| 
 | |
| func Test_spdyFrame_Parse(t *testing.T) {
 | |
| 	zl, err := zap.NewDevelopment()
 | |
| 	if err != nil {
 | |
| 		t.Fatal(err)
 | |
| 	}
 | |
| 	tests := []struct {
 | |
| 		name      string
 | |
| 		gotBytes  []byte
 | |
| 		wantFrame spdyFrame
 | |
| 		wantOk    bool
 | |
| 		wantErr   bool
 | |
| 	}{
 | |
| 		{
 | |
| 			name:     "control_frame_syn_stream",
 | |
| 			gotBytes: []byte{0x80, 0x3, 0x0, 0x1, 0x0, 0x0, 0x0, 0x0},
 | |
| 			wantFrame: spdyFrame{
 | |
| 				Version: 3,
 | |
| 				Type:    SYN_STREAM,
 | |
| 				Ctrl:    true,
 | |
| 				Raw:     []byte{0x80, 0x3, 0x0, 0x1, 0x0, 0x0, 0x0, 0x0},
 | |
| 				Payload: []byte{},
 | |
| 			},
 | |
| 			wantOk: true,
 | |
| 		},
 | |
| 		{
 | |
| 			name:     "control_frame_syn_reply",
 | |
| 			gotBytes: []byte{0x80, 0x3, 0x0, 0x2, 0x0, 0x0, 0x0, 0x0},
 | |
| 			wantFrame: spdyFrame{
 | |
| 				Ctrl:    true,
 | |
| 				Version: 3,
 | |
| 				Type:    SYN_REPLY,
 | |
| 				Raw:     []byte{0x80, 0x3, 0x0, 0x2, 0x0, 0x0, 0x0, 0x0},
 | |
| 				Payload: []byte{},
 | |
| 			},
 | |
| 			wantOk: true,
 | |
| 		},
 | |
| 		{
 | |
| 			name:     "control_frame_headers",
 | |
| 			gotBytes: []byte{0x80, 0x3, 0x0, 0x8, 0x0, 0x0, 0x0, 0x0},
 | |
| 			wantFrame: spdyFrame{
 | |
| 				Ctrl:    true,
 | |
| 				Version: 3,
 | |
| 				Type:    8,
 | |
| 				Raw:     []byte{0x80, 0x3, 0x0, 0x8, 0x0, 0x0, 0x0, 0x0},
 | |
| 				Payload: []byte{},
 | |
| 			},
 | |
| 			wantOk: true,
 | |
| 		},
 | |
| 		{
 | |
| 			name:     "data_frame_stream_id_5",
 | |
| 			gotBytes: []byte{0x0, 0x0, 0x0, 0x5, 0x0, 0x0, 0x0, 0x0},
 | |
| 			wantFrame: spdyFrame{
 | |
| 				Payload:  []byte{},
 | |
| 				StreamID: 5,
 | |
| 				Raw:      []byte{0x0, 0x0, 0x0, 0x5, 0x0, 0x0, 0x0, 0x0},
 | |
| 			},
 | |
| 			wantOk: true,
 | |
| 		},
 | |
| 		{
 | |
| 			name:     "frame_with_incomplete_header",
 | |
| 			gotBytes: []byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
 | |
| 		},
 | |
| 		{
 | |
| 			name:     "frame_with_incomplete_payload",
 | |
| 			gotBytes: []byte{0x0, 0x0, 0x0, 0x5, 0x0, 0x0, 0x0, 0x2}, // header specifies payload length of 2
 | |
| 		},
 | |
| 		{
 | |
| 			name:     "control_bit_set_not_spdy_frame",
 | |
| 			gotBytes: []byte{0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, // header specifies payload length of 2
 | |
| 			wantErr:  true,
 | |
| 		},
 | |
| 		{
 | |
| 			name:     "control_bit_not_set_not_spdy_frame",
 | |
| 			gotBytes: []byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, // header specifies payload length of 2
 | |
| 			wantErr:  true,
 | |
| 		},
 | |
| 	}
 | |
| 	for _, tt := range tests {
 | |
| 		t.Run(tt.name, func(t *testing.T) {
 | |
| 			sf := &spdyFrame{}
 | |
| 			gotOk, err := sf.Parse(tt.gotBytes, zl.Sugar())
 | |
| 			if (err != nil) != tt.wantErr {
 | |
| 				t.Errorf("spdyFrame.Parse() error = %v, wantErr %v", err, tt.wantErr)
 | |
| 				return
 | |
| 			}
 | |
| 			if gotOk != tt.wantOk {
 | |
| 				t.Errorf("spdyFrame.Parse() = %v, want %v", gotOk, tt.wantOk)
 | |
| 			}
 | |
| 			if diff := cmp.Diff(*sf, tt.wantFrame); diff != "" {
 | |
| 				t.Errorf("Unexpected SPDY frame (-got +want):\n%s", diff)
 | |
| 			}
 | |
| 		})
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func Test_spdyFrame_parseHeaders(t *testing.T) {
 | |
| 	zl, err := zap.NewDevelopment()
 | |
| 	if err != nil {
 | |
| 		t.Fatal(err)
 | |
| 	}
 | |
| 	tests := []struct {
 | |
| 		name       string
 | |
| 		isCtrl     bool
 | |
| 		payload    []byte
 | |
| 		typ        ControlFrameType
 | |
| 		wantHeader http.Header
 | |
| 		wantErr    bool
 | |
| 	}{
 | |
| 		{
 | |
| 			name:       "syn_stream_with_header",
 | |
| 			payload:    payload(t, map[string]string{"Streamtype": "stdin"}, SYN_STREAM, 1),
 | |
| 			typ:        SYN_STREAM,
 | |
| 			isCtrl:     true,
 | |
| 			wantHeader: header(map[string]string{"Streamtype": "stdin"}),
 | |
| 		},
 | |
| 		{
 | |
| 			name:    "syn_ping",
 | |
| 			payload: payload(t, nil, SYN_PING, 0),
 | |
| 			typ:     SYN_PING,
 | |
| 			isCtrl:  true,
 | |
| 		},
 | |
| 		{
 | |
| 			name:       "syn_reply_headers",
 | |
| 			payload:    payload(t, map[string]string{"foo": "bar", "bar": "baz"}, SYN_REPLY, 0),
 | |
| 			typ:        SYN_REPLY,
 | |
| 			isCtrl:     true,
 | |
| 			wantHeader: header(map[string]string{"foo": "bar", "bar": "baz"}),
 | |
| 		},
 | |
| 		{
 | |
| 			name:    "syn_reply_no_headers",
 | |
| 			payload: payload(t, nil, SYN_REPLY, 0),
 | |
| 			typ:     SYN_REPLY,
 | |
| 			isCtrl:  true,
 | |
| 		},
 | |
| 		{
 | |
| 			name:    "syn_stream_too_short_payload",
 | |
| 			payload: []byte{0, 1, 2, 3, 4},
 | |
| 			typ:     SYN_STREAM,
 | |
| 			isCtrl:  true,
 | |
| 			wantErr: true,
 | |
| 		},
 | |
| 		{
 | |
| 			name:    "syn_reply_too_short_payload",
 | |
| 			payload: []byte{0, 1, 2},
 | |
| 			typ:     SYN_REPLY,
 | |
| 			isCtrl:  true,
 | |
| 			wantErr: true,
 | |
| 		},
 | |
| 		{
 | |
| 			name:    "syn_ping_too_short_payload",
 | |
| 			payload: []byte{0, 1, 2},
 | |
| 			typ:     SYN_PING,
 | |
| 			isCtrl:  true,
 | |
| 			wantErr: true,
 | |
| 		},
 | |
| 		{
 | |
| 			name:    "not_a_control_frame",
 | |
| 			payload: []byte{0, 1, 2, 3},
 | |
| 			typ:     SYN_PING,
 | |
| 			wantErr: true,
 | |
| 		},
 | |
| 	}
 | |
| 	for _, tt := range tests {
 | |
| 		var reader zlibReader
 | |
| 		t.Run(tt.name, func(t *testing.T) {
 | |
| 			sf := &spdyFrame{
 | |
| 				Ctrl:    tt.isCtrl,
 | |
| 				Type:    tt.typ,
 | |
| 				Payload: tt.payload,
 | |
| 			}
 | |
| 			gotHeader, err := sf.parseHeaders(&reader, zl.Sugar())
 | |
| 			if (err != nil) != tt.wantErr {
 | |
| 				t.Errorf("spdyFrame.parseHeaders() error = %v, wantErr %v", err, tt.wantErr)
 | |
| 			}
 | |
| 			if !reflect.DeepEqual(gotHeader, tt.wantHeader) {
 | |
| 				t.Errorf("spdyFrame.parseHeaders() = %v, want %v", gotHeader, tt.wantHeader)
 | |
| 			}
 | |
| 		})
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // Test_spdyFrame_ParseRand calls spdyFrame.Parse with randomly generated bytes
 | |
| // to test that it doesn't panic.
 | |
| func Test_spdyFrame_ParseRand(t *testing.T) {
 | |
| 	zl, err := zap.NewDevelopment()
 | |
| 	if err != nil {
 | |
| 		t.Fatal(err)
 | |
| 	}
 | |
| 	r := rand.New(rand.NewSource(time.Now().UnixNano()))
 | |
| 	for i := range 100 {
 | |
| 		n := r.Intn(4096)
 | |
| 		b := make([]byte, n)
 | |
| 		_, err := r.Read(b)
 | |
| 		if err != nil {
 | |
| 			t.Fatalf("error generating random byte slice: %v", err)
 | |
| 		}
 | |
| 		sf := &spdyFrame{}
 | |
| 		f := func() {
 | |
| 			sf.Parse(b, zl.Sugar())
 | |
| 		}
 | |
| 		testPanic(t, f, fmt.Sprintf("[%d] Parse panicked running with byte slice of length %d: %v", i, n, r))
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // payload takes a control frame type and a map with 0 or more header keys and
 | |
| // values and returns a SPDY control frame payload with the header as SPDY zlib
 | |
| // compressed header name/value block. The payload is padded with arbitrary
 | |
| // bytes to ensure the header name/value block is in the correct position for
 | |
| // the frame type.
 | |
| func payload(t *testing.T, headerM map[string]string, typ ControlFrameType, streamID int) []byte {
 | |
| 	t.Helper()
 | |
| 
 | |
| 	buf := bytes.NewBuffer([]byte{})
 | |
| 	writeControlFramePayloadBeforeHeaders(t, buf, typ, streamID)
 | |
| 	if len(headerM) == 0 {
 | |
| 		return buf.Bytes()
 | |
| 	}
 | |
| 
 | |
| 	w, err := zlib.NewWriterLevelDict(buf, zlib.BestCompression, spdyTxtDictionary)
 | |
| 	if err != nil {
 | |
| 		t.Fatalf("error creating new zlib writer: %v", err)
 | |
| 	}
 | |
| 	if len(headerM) != 0 {
 | |
| 		writeHeaderValueBlock(t, w, headerM)
 | |
| 	}
 | |
| 	if err != nil {
 | |
| 		t.Fatalf("error writing headers: %v", err)
 | |
| 	}
 | |
| 	w.Flush()
 | |
| 	return buf.Bytes()
 | |
| }
 | |
| 
 | |
| // writeControlFramePayloadBeforeHeaders writes to w N bytes, N being the number
 | |
| // of bytes that control frame payload for that control frame is required to
 | |
| // contain before the name/value header block.
 | |
| func writeControlFramePayloadBeforeHeaders(t *testing.T, w io.Writer, typ ControlFrameType, streamID int) {
 | |
| 	t.Helper()
 | |
| 	switch typ {
 | |
| 	case SYN_STREAM:
 | |
| 		// needs 10 bytes in payload before any headers
 | |
| 		if err := binary.Write(w, binary.BigEndian, uint32(streamID)); err != nil {
 | |
| 			t.Fatalf("writing streamID: %v", err)
 | |
| 		}
 | |
| 		if err := binary.Write(w, binary.BigEndian, [6]byte{0}); err != nil {
 | |
| 			t.Fatalf("writing payload: %v", err)
 | |
| 		}
 | |
| 	case SYN_REPLY:
 | |
| 		// needs 4 bytes in payload before any headers
 | |
| 		if err := binary.Write(w, binary.BigEndian, uint32(0)); err != nil {
 | |
| 			t.Fatalf("writing payload: %v", err)
 | |
| 		}
 | |
| 	case SYN_PING:
 | |
| 		// needs 4 bytes in payload
 | |
| 		if err := binary.Write(w, binary.BigEndian, uint32(0)); err != nil {
 | |
| 			t.Fatalf("writing payload: %v", err)
 | |
| 		}
 | |
| 	default:
 | |
| 		t.Fatalf("unexpected frame type: %v", typ)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // writeHeaderValue block takes http.Header and zlib writer, writes the headers
 | |
| // as SPDY zlib compressed bytes to the writer.
 | |
| // Adopted from https://github.com/moby/spdystream/blob/v0.2.0/spdy/write.go#L171-L198 (which is also what Kubernetes uses).
 | |
| func writeHeaderValueBlock(t *testing.T, w io.Writer, headerM map[string]string) {
 | |
| 	t.Helper()
 | |
| 	h := header(headerM)
 | |
| 	if err := binary.Write(w, binary.BigEndian, uint32(len(h))); err != nil {
 | |
| 		t.Fatalf("error writing header block length: %v", err)
 | |
| 	}
 | |
| 	for name, values := range h {
 | |
| 		if err := binary.Write(w, binary.BigEndian, uint32(len(name))); err != nil {
 | |
| 			t.Fatalf("error writing name length for name %q: %v", name, err)
 | |
| 		}
 | |
| 		name = strings.ToLower(name)
 | |
| 		if _, err := io.WriteString(w, name); err != nil {
 | |
| 			t.Fatalf("error writing name %q: %v", name, err)
 | |
| 		}
 | |
| 		v := strings.Join(values, string(headerSep))
 | |
| 		if err := binary.Write(w, binary.BigEndian, uint32(len(v))); err != nil {
 | |
| 			t.Fatalf("error writing value length for value %q: %v", v, err)
 | |
| 		}
 | |
| 		if _, err := io.WriteString(w, v); err != nil {
 | |
| 			t.Fatalf("error writing value %q: %v", v, err)
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func header(hs map[string]string) http.Header {
 | |
| 	h := make(http.Header, len(hs))
 | |
| 	for key, val := range hs {
 | |
| 		h.Add(key, val)
 | |
| 	}
 | |
| 	return h
 | |
| }
 | |
| 
 | |
| func testPanic(t *testing.T, f func(), msg string) {
 | |
| 	t.Helper()
 | |
| 	defer func() {
 | |
| 		if r := recover(); r != nil {
 | |
| 			t.Fatal(msg, r)
 | |
| 		}
 | |
| 	}()
 | |
| 	f()
 | |
| }
 |