diff --git a/ssh/tailssh/tailssh.go b/ssh/tailssh/tailssh.go index 2dce300bb..d642ad0e4 100644 --- a/ssh/tailssh/tailssh.go +++ b/ssh/tailssh/tailssh.go @@ -62,7 +62,6 @@ type ipnLocalBackend interface { NetMap() *netmap.NetworkMap WhoIs(ipp netip.AddrPort) (n *tailcfg.Node, u tailcfg.UserProfile, ok bool) DoNoiseRequest(req *http.Request) (*http.Response, error) - TailscaleVarRoot() string } type server struct { @@ -987,12 +986,6 @@ 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.RegisterBool("TS_DEBUG_LOG_SSH") - // run is the entrypoint for a newly accepted SSH session. // // It handles ss once it's been accepted and determined @@ -1127,10 +1120,9 @@ func (ss *sshSession) run() { 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. + // TODO(bradfitz,maisem): support recording non-pty stuff too. _, _, isPtyReq := ss.Pty() - return recordSSH() && isPtyReq + return isPtyReq && len(ss.conn.finalAction.Recorders) > 0 } type sshConnInfo struct { @@ -1313,10 +1305,15 @@ func randBytes(n int) []byte { } // 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, err error) { + if len(ss.conn.finalAction.Recorders) == 0 { + return nil, errors.New("no recorders configured") + } + recorder := ss.conn.finalAction.Recorders[0] + if len(ss.conn.finalAction.Recorders) > 1 { + ss.logf("warning: multiple recorders configured, using first one: %v", recorder) + } + var w ssh.Window if ptyReq, _, isPtyReq := ss.Pty(); isPtyReq { w = ptyReq.Window @@ -1332,25 +1329,33 @@ func (ss *sshSession) startNewRecording() (_ *recording, err error) { ss: ss, start: now, } - varRoot := ss.conn.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 { + + pr, pw := io.Pipe() + req, err := http.NewRequestWithContext(ss.ctx, "POST", fmt.Sprintf("http://%s:%d/record", recorder.Addr(), recorder.Port()), pr) + if err != nil { + pr.Close() + pw.Close() return nil, err } - defer func() { + go func() { + defer pw.Close() + ss.logf("starting asciinema recording to %s", recorder) + + // We just use the default client here, which has a 30s dial timeout. + resp, err := http.DefaultClient.Do(req) if err != nil { - rec.Close() + ss.cancelCtx(err) + ss.logf("recording: error sending recording to %s: %v", recorder, err) + return + } + defer resp.Body.Close() + defer ss.cancelCtx(errors.New("recording: done")) + if resp.StatusCode != http.StatusOK { + ss.logf("recording: error sending recording to %s: %v", recorder, resp.Status) } }() - f, err := os.CreateTemp(dir, fmt.Sprintf("ssh-session-%v-*.cast", now.UnixNano())) - if err != nil { - return nil, err - } - rec.out = f + rec.out = pw // {"version": 2, "width": 221, "height": 84, "timestamp": 1647146075, "env": {"SHELL": "/bin/bash", "TERM": "screen"}} type CastHeader struct { @@ -1359,6 +1364,12 @@ func (ss *sshSession) startNewRecording() (_ *recording, err error) { Height int `json:"height"` Timestamp int64 `json:"timestamp"` Env map[string]string `json:"env"` + + // Tailscale-specific fields: + SrcNode string `json:"srcNode"` // name + SrcNodeID tailcfg.StableNodeID `json:"srcNodeID"` + SSHUser string `json:"sshUser"` + LocalUser string `json:"localUser"` } j, err := json.Marshal(CastHeader{ Version: 2, @@ -1376,15 +1387,16 @@ func (ss *sshSession) startNewRecording() (_ *recording, err error) { // it. Then we can (1) make the cmd, (2) start the // recording, (3) start the process. }, + SSHUser: ss.conn.info.sshUser, + LocalUser: ss.conn.localUser.Username, + SrcNode: ss.conn.info.node.Name, + SrcNodeID: ss.conn.info.node.StableID, }) 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() + if _, err := pw.Write(j); err != nil { return nil, err } return rec, nil @@ -1396,7 +1408,7 @@ type recording struct { start time.Time mu sync.Mutex // guards writes to, close of out - out *os.File // nil if closed + out io.WriteCloser } func (r *recording) Close() error { @@ -1415,10 +1427,17 @@ func (r *recording) Close() error { // The dir should be "i" for input or "o" for output. // // If r is nil, it returns w unchanged. +// +// Currently (2023-03-21) we only record output, not input. func (r *recording) writer(dir string, w io.Writer) io.Writer { if r == nil { return w } + if dir == "i" { + // TODO: record input? Maybe not, since it might contain + // passwords. + return w + } return &loggingWriter{r, dir, w} }