mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 19:15:34 +00:00
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()
|
|
}
|