mirror of https://github.com/tailscale/tailscale/
cmd/k8s-operator,k8s-operator/sessionrecording,sessionrecording,ssh/tailssh: refactor session recording functionality (#12945)
cmd/k8s-operator,k8s-operator/sessionrecording,sessionrecording,ssh/tailssh: refactor session recording functionality Refactor SSH session recording functionality (mostly the bits related to Kubernetes API server proxy 'kubectl exec' session recording): - move the session recording bits used by both Tailscale SSH and the Kubernetes API server proxy into a shared sessionrecording package, to avoid having the operator to import ssh/tailssh - move the Kubernetes API server proxy session recording functionality into a k8s-operator/sessionrecording package, add some abstractions in preparation for adding support for a second streaming protocol (WebSockets) Updates tailscale/corp#19821 Signed-off-by: Irbe Krumina <irbe@tailscale.com>pull/12951/head
parent
1bf7ed0348
commit
a21bf100f3
@ -0,0 +1,20 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !plan9
|
||||||
|
|
||||||
|
// Package conn contains shared interface for the hijacked
|
||||||
|
// connection of a 'kubectl exec' session that is being recorded.
|
||||||
|
package conn
|
||||||
|
|
||||||
|
import "net"
|
||||||
|
|
||||||
|
type Conn interface {
|
||||||
|
net.Conn
|
||||||
|
// Fail can be called to set connection state to failed. By default any
|
||||||
|
// bytes left over in write buffer are forwarded to the intended
|
||||||
|
// destination when the connection is being closed except for when the
|
||||||
|
// connection state is failed- so set the state to failed when erroring
|
||||||
|
// out and failure policy is to fail closed.
|
||||||
|
Fail()
|
||||||
|
}
|
@ -0,0 +1,118 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !plan9
|
||||||
|
|
||||||
|
// Package fakes contains mocks used for testing 'kubectl exec' session
|
||||||
|
// recording functionality.
|
||||||
|
package fakes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"tailscale.com/sessionrecording"
|
||||||
|
"tailscale.com/tstime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(conn net.Conn, wb bytes.Buffer, rb bytes.Buffer, closed bool) net.Conn {
|
||||||
|
return &TestConn{
|
||||||
|
Conn: conn,
|
||||||
|
writeBuf: wb,
|
||||||
|
readBuf: rb,
|
||||||
|
closed: closed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestConn struct {
|
||||||
|
net.Conn
|
||||||
|
// writeBuf contains whatever was send to the conn via Write.
|
||||||
|
writeBuf bytes.Buffer
|
||||||
|
// readBuf contains whatever was sent to the conn via Read.
|
||||||
|
readBuf bytes.Buffer
|
||||||
|
sync.RWMutex // protects the following
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ net.Conn = &TestConn{}
|
||||||
|
|
||||||
|
func (tc *TestConn) Read(b []byte) (int, error) {
|
||||||
|
return tc.readBuf.Read(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestConn) Write(b []byte) (int, error) {
|
||||||
|
return tc.writeBuf.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestConn) Close() error {
|
||||||
|
tc.Lock()
|
||||||
|
defer tc.Unlock()
|
||||||
|
tc.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestConn) IsClosed() bool {
|
||||||
|
tc.Lock()
|
||||||
|
defer tc.Unlock()
|
||||||
|
return tc.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestConn) WriteBufBytes() []byte {
|
||||||
|
return tc.writeBuf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestConn) ResetReadBuf() {
|
||||||
|
tc.readBuf.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestConn) WriteReadBufBytes(b []byte) error {
|
||||||
|
_, err := tc.readBuf.Write(b)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestSessionRecorder struct {
|
||||||
|
// buf holds data that was sent to the session recorder.
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TestSessionRecorder) Write(b []byte) (int, error) {
|
||||||
|
return t.buf.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TestSessionRecorder) Close() error {
|
||||||
|
t.buf.Reset()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TestSessionRecorder) Bytes() []byte {
|
||||||
|
return t.buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func CastLine(t *testing.T, p []byte, clock tstime.Clock) []byte {
|
||||||
|
t.Helper()
|
||||||
|
j, err := json.Marshal([]any{
|
||||||
|
clock.Now().Sub(clock.Now()).Seconds(),
|
||||||
|
"o",
|
||||||
|
string(p),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error marshalling cast line: %v", err)
|
||||||
|
}
|
||||||
|
return append(j, '\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
func AsciinemaResizeMsg(t *testing.T, width, height int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
ch := sessionrecording.CastHeader{
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
}
|
||||||
|
bs, err := json.Marshal(ch)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error marshalling CastHeader: %v", err)
|
||||||
|
}
|
||||||
|
return append(bs, '\n')
|
||||||
|
}
|
@ -1,7 +1,9 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
package tailssh
|
// Package sessionrecording contains session recording utils shared amongst
|
||||||
|
// Tailscale SSH and Kubernetes API server proxy session recording.
|
||||||
|
package sessionrecording
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
@ -0,0 +1,78 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package sessionrecording
|
||||||
|
|
||||||
|
import "tailscale.com/tailcfg"
|
||||||
|
|
||||||
|
// CastHeader is the header of an asciinema file.
|
||||||
|
type CastHeader struct {
|
||||||
|
// Version is the asciinema file format version.
|
||||||
|
Version int `json:"version"`
|
||||||
|
|
||||||
|
// Width is the terminal width in characters.
|
||||||
|
// It is non-zero for Pty sessions.
|
||||||
|
Width int `json:"width"`
|
||||||
|
|
||||||
|
// Height is the terminal height in characters.
|
||||||
|
// It is non-zero for Pty sessions.
|
||||||
|
Height int `json:"height"`
|
||||||
|
|
||||||
|
// Timestamp is the unix timestamp of when the recording started.
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
|
||||||
|
// Command is the command that was executed.
|
||||||
|
// Typically empty for shell sessions.
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
|
||||||
|
// SrcNode 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"
|
||||||
|
SrcNode string `json:"srcNode"`
|
||||||
|
|
||||||
|
// SrcNodeID is the node ID of the node originating the connection.
|
||||||
|
SrcNodeID tailcfg.StableNodeID `json:"srcNodeID"`
|
||||||
|
|
||||||
|
// Tailscale-specific fields:
|
||||||
|
// SrcNodeTags is the list of tags on the node originating the connection (if any).
|
||||||
|
SrcNodeTags []string `json:"srcNodeTags,omitempty"`
|
||||||
|
|
||||||
|
// SrcNodeUserID is the user ID of the node originating the connection (if not tagged).
|
||||||
|
SrcNodeUserID tailcfg.UserID `json:"srcNodeUserID,omitempty"` // if not tagged
|
||||||
|
|
||||||
|
// SrcNodeUser is the LoginName of the node originating the connection (if not tagged).
|
||||||
|
SrcNodeUser string `json:"srcNodeUser,omitempty"`
|
||||||
|
|
||||||
|
// Fields that are only set for Tailscale SSH session recordings:
|
||||||
|
|
||||||
|
// Env is the environment variables of the session.
|
||||||
|
// Only "TERM" is set (2023-03-22).
|
||||||
|
Env map[string]string `json:"env"`
|
||||||
|
|
||||||
|
// SSHUser is the username as presented by the client.
|
||||||
|
SSHUser string `json:"sshUser"` // as presented by the client
|
||||||
|
|
||||||
|
// LocalUser is the effective username on the server.
|
||||||
|
LocalUser string `json:"localUser"`
|
||||||
|
|
||||||
|
// ConnectionID uniquely identifies a connection made to the SSH server.
|
||||||
|
// It may be shared across multiple sessions over the same connection in
|
||||||
|
// case of SSH multiplexing.
|
||||||
|
ConnectionID string `json:"connectionID"`
|
||||||
|
|
||||||
|
// Fields that are only set for Kubernetes API server proxy session recordings:
|
||||||
|
|
||||||
|
Kubernetes *Kubernetes `json:"kubernetes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kubernetes contains 'kubectl exec' session specific information for
|
||||||
|
// tsrecorder.
|
||||||
|
type Kubernetes struct {
|
||||||
|
// PodName is the name of the Pod being exec-ed.
|
||||||
|
PodName string
|
||||||
|
// Namespace is the namespace in which is the Pod that is being exec-ed.
|
||||||
|
Namespace string
|
||||||
|
// Container is the container being exec-ed.
|
||||||
|
Container string
|
||||||
|
}
|
Loading…
Reference in New Issue