diff --git a/cmd/sshpiperd/asciicast.go b/cmd/sshpiperd/asciicast.go new file mode 100644 index 000000000..5d2c6b17d --- /dev/null +++ b/cmd/sshpiperd/asciicast.go @@ -0,0 +1,125 @@ +package main + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "os" + "path" + "time" +) + +const ( + msgChannelRequest = 98 +) + +func jsonEscape(i string) string { + b, err := json.Marshal(i) + if err != nil { + panic(err) + } + s := string(b) + return s[1 : len(s)-1] +} + +func readString(buf *bytes.Reader) string { + var l uint32 + binary.Read(buf, binary.BigEndian, &l) + s := make([]byte, l) + buf.Read(s) + return string(s) +} + +type asciicastLogger struct { + cast *os.File + starttime time.Time + envs map[string]string + initWidth uint32 + initHeight uint32 +} + +func newAsciicastLogger(logdir string) (*asciicastLogger, error) { + f, err := os.OpenFile(path.Join(logdir, "shell.cast"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + + if err != nil { + return nil, err + } + + return &asciicastLogger{ + cast: f, + starttime: time.Now(), + envs: make(map[string]string), + }, nil +} + +func (l *asciicastLogger) uphook(msg []byte) ([]byte, error) { + if msg[0] == msgChannelData { + buf := msg[9:] + t := time.Since(l.starttime).Seconds() + + _, err := fmt.Fprintf(l.cast, "[%v,\"o\",\"%s\"]\n", t, jsonEscape(string(buf))) + + if err != nil { + return msg, err + } + + } + return msg, nil +} + +func (l *asciicastLogger) downhook(msg []byte) ([]byte, error) { + if msg[0] == msgChannelRequest { + t := time.Since(l.starttime).Seconds() + buf := bytes.NewReader(msg[5:]) + reqType := readString(buf) + + switch reqType { + case "pty-req": + buf.ReadByte() + term := readString(buf) + binary.Read(buf, binary.BigEndian, &l.initWidth) + binary.Read(buf, binary.BigEndian, &l.initHeight) + l.envs["TERM"] = term + case "env": + buf.ReadByte() + varName := readString(buf) + varValue := readString(buf) + l.envs[varName] = varValue + case "window-change": + buf.ReadByte() + var width, height uint32 + binary.Read(buf, binary.BigEndian, &width) + binary.Read(buf, binary.BigEndian, &height) + _, err := fmt.Fprintf(l.cast, "[%v,\"r\", \"%vx%v\"]\n", t, width, height) + if err != nil { + return msg, err + } + case "shell", "exec": + jsonEnvs, err := json.Marshal(l.envs) + + if err != nil { + return msg, err + } + + _, err = fmt.Fprintf( + l.cast, + "{\"version\": 2, \"width\": %d, \"height\": %d, \"timestamp\": %d, \"env\": %v}\n", + l.initWidth, + l.initHeight, + l.starttime.Unix(), + string(jsonEnvs), + ) + + if err != nil { + return msg, err + } + } + } + return msg, nil +} + +func (l *asciicastLogger) Close() (err error) { + l.cast.Close() + return nil +} diff --git a/cmd/sshpiperd/daemon.go b/cmd/sshpiperd/daemon.go index dd04f7f62..dfcdf4b12 100644 --- a/cmd/sshpiperd/daemon.go +++ b/cmd/sshpiperd/daemon.go @@ -24,6 +24,7 @@ type daemon struct { loginGraceTime time.Duration recorddir string + asciicastdir string filterHostkeysReqeust bool } @@ -217,19 +218,32 @@ func (d *daemon) run() error { var uphook func([]byte) ([]byte, error) var downhook func([]byte) ([]byte, error) - if d.recorddir != "" { - recorddir := path.Join(d.recorddir, p.DownstreamConnMeta().User()) + uniqID := plugin.GetUniqueID(p.ChallengeContext()) + if d.asciicastdir != "" { + recorddir := path.Join(d.asciicastdir, uniqID) err = os.MkdirAll(recorddir, 0700) if err != nil { log.Errorf("cannot create screen recording dir %v: %v", recorddir, err) return } + recorder, err := newAsciicastLogger(recorddir) + if err != nil { + log.Errorf("cannot create screen recording logger: %v", err) + return + } + defer recorder.Close() + uphook = recorder.uphook + downhook = recorder.downhook + } else if d.recorddir != "" { + recorddir := path.Join(d.recorddir, uniqID) + err = os.MkdirAll(recorddir, 0700) recorder, err := newFilePtyLogger(recorddir) if err != nil { log.Errorf("cannot create screen recording logger: %v", err) return } + defer recorder.Close() uphook = recorder.loggingTty } diff --git a/cmd/sshpiperd/internal/plugin/grpc.go b/cmd/sshpiperd/internal/plugin/grpc.go index d882cd046..3b5f8937b 100644 --- a/cmd/sshpiperd/internal/plugin/grpc.go +++ b/cmd/sshpiperd/internal/plugin/grpc.go @@ -592,3 +592,13 @@ func DialCmd(cmd *exec.Cmd) (*CmdPlugin, error) { return &CmdPlugin{*g, ch}, nil } + +func GetUniqueID(ctx ssh.ChallengeContext) string { + switch meta := ctx.(type) { + case *connMeta: + return meta.UniqId + case *chainConnMeta: + return meta.UniqId + } + panic("unknown challenge context") +} diff --git a/cmd/sshpiperd/main.go b/cmd/sshpiperd/main.go index c5eb0e23b..9a065c8ac 100644 --- a/cmd/sshpiperd/main.go +++ b/cmd/sshpiperd/main.go @@ -142,6 +142,12 @@ func main() { Usage: "create typescript format screen recording and save into the directory see https://linux.die.net/man/1/script", EnvVars: []string{"SSHPIPERD_TYPESCRIPT_LOG_DIR"}, }, + &cli.StringFlag{ + Name: "asciicast-log-dir", + Value: "", + Usage: "create asciicast v2 format screen recording and save into the directory see https://docs.asciinema.org/manual/asciicast/v2", + EnvVars: []string{"SSHPIPERD_ASCIICAST_LOG_DIR"}, + }, &cli.StringFlag{ Name: "banner-text", Value: "", @@ -257,6 +263,7 @@ func main() { } d.recorddir = ctx.String("typescript-log-dir") + d.asciicastdir = ctx.String("asciicast-log-dir") d.filterHostkeysReqeust = ctx.Bool("drop-hostkeys-message") go func() {