From f30473211b72ddf03eb8f3db97cfc61b5ee84c5b Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Sat, 12 Mar 2022 21:32:17 -0800 Subject: [PATCH] ssh/tailssh: start of implementing optional session recording To asciinema cast format. Updates #3802 Change-Id: Ifd3ea31922cd2c99068369cb1650e21f2545b0e1 Signed-off-by: Brad Fitzpatrick --- ssh/tailssh/tailssh.go | 198 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 193 insertions(+), 5 deletions(-) diff --git a/ssh/tailssh/tailssh.go b/ssh/tailssh/tailssh.go index 4fe898220..53c322895 100644 --- a/ssh/tailssh/tailssh.go +++ b/ssh/tailssh/tailssh.go @@ -15,12 +15,14 @@ import ( "errors" "fmt" "io" + "io/ioutil" "net" "net/http" "os" "os/exec" "os/user" "path/filepath" + "runtime" "strconv" "strings" "sync" @@ -432,11 +434,16 @@ func (ss *sshSession) handleSSHAgentForwarding(s ssh.Session, lu *user.User) err return nil } +// recordSSH is a temporary dev knob to test the SSH recording +// functionality and support off-node streaming. +// +// TODO(bradfitz,maisem): move this to SSHPolicy. +var recordSSH = envknob.Bool("TS_DEBUG_LOG_SSH") + // run is the entrypoint for a newly accepted SSH session. // -// When ctx is done, the session is forcefully terminated. If its Err -// is an SSHTerminationError, its SSHTerminationMessage is sent to the -// user. +// It handles ss once it's been accepted and determined +// that it should run. func (ss *sshSession) run() { srv := ss.srv srv.startSession(ss) @@ -477,6 +484,20 @@ func (ss *sshSession) run() { // TODO(maisem/bradfitz): add a way to close all session resources defer ss.agentListener.Close() } + + var rec *recording // or nil if disabled + if ss.shouldRecord() { + var err error + rec, err = ss.startNewRecording() + if err != nil { + fmt.Fprintf(ss, "can't start new recording\n") + logf("startNewRecording: %v", err) + ss.Exit(1) + return + } + defer rec.Close() + } + err := ss.launchProcess(ss.ctx) if err != nil { logf("start failed: %v", err.Error()) @@ -486,7 +507,7 @@ func (ss *sshSession) run() { go ss.killProcessOnContextDone() go func() { - _, err := io.Copy(ss.stdin, ss) + _, err := io.Copy(rec.writer("i", ss.stdin), ss) if err != nil { // TODO: don't log in the success case. logf("ssh: stdin copy: %v", err) @@ -494,7 +515,7 @@ func (ss *sshSession) run() { ss.stdin.Close() }() go func() { - _, err := io.Copy(ss, ss.stdout) + _, err := io.Copy(rec.writer("o", ss), ss.stdout) if err != nil { // TODO: don't log in the success case. logf("ssh: stdout copy: %v", err) @@ -533,6 +554,14 @@ func (ss *sshSession) run() { return } +func (ss *sshSession) shouldRecord() bool { + // for now only record pty sessions + // TODO(bradfitz,maisem): make configurable on SSHPolicy and + // support recording non-pty stuff too. + _, _, isPtyReq := ss.Pty() + return recordSSH && isPtyReq +} + type sshConnInfo struct { // now is the time to consider the present moment for the // purposes of rule evaluation. @@ -631,3 +660,162 @@ func randBytes(n int) []byte { } return b } + +// startNewRecording starts a new SSH session recording. +// +// It writes an asciinema file to +// $TAILSCALE_VAR_ROOT/ssh-sessions/ssh-session--*.cast. +func (ss *sshSession) startNewRecording() (*recording, error) { + var w ssh.Window + if ptyReq, _, isPtyReq := ss.Pty(); isPtyReq { + w = ptyReq.Window + } + + term := envValFromList(ss.Environ(), "TERM") + if term == "" { + term = "xterm-256color" // something non-empty + } + + now := time.Now() + rec := &recording{ + ss: ss, + start: now, + } + varRoot := ss.srv.lb.TailscaleVarRoot() + if varRoot == "" { + return nil, errors.New("no var root for recording storage") + } + dir := filepath.Join(varRoot, "ssh-sessions") + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + f, err := ioutil.TempFile(dir, fmt.Sprintf("ssh-session-%v-*.cast", now.UnixNano())) + if err != nil { + return nil, err + } + rec.out = f + + // {"version": 2, "width": 221, "height": 84, "timestamp": 1647146075, "env": {"SHELL": "/bin/bash", "TERM": "screen"}} + type CastHeader struct { + Version int `json:"version"` + Width int `json:"width"` + Height int `json:"height"` + Timestamp int64 `json:"timestamp"` + Env map[string]string `json:"env"` + } + j, err := json.Marshal(CastHeader{ + Version: 2, + Width: w.Width, + Height: w.Height, + Timestamp: now.Unix(), + Env: map[string]string{ + "TERM": term, + // TODO(bradiftz): anything else important? + // including all seems noisey, but maybe we should + // for auditing. But first need to break + // launchProcess's startWithStdPipes and + // startWithPTY up so that they first return the cmd + // without starting it, and then a step that starts + // it. Then we can (1) make the cmd, (2) start the + // recording, (3) start the process. + }, + }) + if err != nil { + f.Close() + return nil, err + } + ss.logf("starting asciinema recording to %s", f.Name()) + j = append(j, '\n') + if _, err := f.Write(j); err != nil { + f.Close() + return nil, err + } + return rec, nil +} + +// recording is the state for an SSH session recording. +type recording struct { + ss *sshSession + start time.Time + + mu sync.Mutex // guards writes to, close of out + out *os.File // nil if closed +} + +func (r *recording) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + if r.out == nil { + return nil + } + err := r.out.Close() + r.out = nil + return err +} + +// writer returns an io.Writer around w that first records the write. +// +// The dir should be "i" for input or "o" for output. +// +// If r is nil, it returns w unchanged. +func (r *recording) writer(dir string, w io.Writer) io.Writer { + if r == nil { + return w + } + return &loggingWriter{r, dir, w} +} + +// loggingWriter is an io.Writer wrapper that writes first an +// asciinema JSON cast format recording line, and then writes to w. +type loggingWriter struct { + r *recording + dir string // "i" or "o" (input or output) + w io.Writer // underlying Writer, after writing to r.out +} + +func (w loggingWriter) Write(p []byte) (n int, err error) { + j, err := json.Marshal([]interface{}{ + time.Since(w.r.start).Seconds(), + w.dir, + string(p), + }) + if err != nil { + return 0, err + } + j = append(j, '\n') + if err := w.writeCastLine(j); err != nil { + return 0, nil + } + return w.w.Write(p) +} + +func (w loggingWriter) writeCastLine(j []byte) error { + w.r.mu.Lock() + defer w.r.mu.Unlock() + if w.r.out == nil { + return errors.New("logger closed") + } + _, err := w.r.out.Write(j) + if err != nil { + return fmt.Errorf("logger Write: %w", err) + } + return nil +} + +func envValFromList(env []string, wantKey string) (v string) { + for _, kv := range env { + if thisKey, v, ok := strings.Cut(kv, "="); ok && envEq(thisKey, wantKey) { + return v + } + } + return "" +} + +// envEq reports whether environment variable a == b for the current +// operating system. +func envEq(a, b string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(a, b) + } + return a == b +}