|
|
|
@ -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}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|