ssh/tailssh: stream SSH recordings to configured recorders

Updates tailscale/corp#9967

Signed-off-by: Maisem Ali <maisem@tailscale.com>
pull/7643/head
Maisem Ali 1 year ago committed by Maisem Ali
parent 60cd4ac08d
commit 916aa782af

@ -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-<unixtime>-*.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}
}

Loading…
Cancel
Save