Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Log user's writes in the TTY #57

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
3 changes: 3 additions & 0 deletions .gotty
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
// [bool] Permit clients to write to the TTY
// permit_write = false

// [bool] Log user's writes in the TTY
// write_log = false

// [bool] Enable basic authentication
// enable_basic_auth = false

Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ By default, GoTTY starts a web server at port 8080. Open the URL on your web bro
--port value, -p value Port number to liten (default: "8080") [$GOTTY_PORT]
--path value, -m value Base path (default: "/") [$GOTTY_PATH]
--permit-write, -w Permit clients to write to the TTY (BE CAREFUL) (default: false) [$GOTTY_PERMIT_WRITE]
--write-log Log user's writes in the TTY (default: false) [$GOTTY_WRITE_LOG]
--credential value, -c value Credential for Basic Authentication (ex: user:pass, default disabled) [$GOTTY_CREDENTIAL]
--random-url, -r Add a random string to the URL (default: false) [$GOTTY_RANDOM_URL]
--random-url-length value Random URL length (default: 8) [$GOTTY_RANDOM_URL_LENGTH]
Expand Down Expand Up @@ -151,6 +152,37 @@ When you want to create a jailed environment for each client, you can use Docker
$ gotty -w docker run -it --rm busybox
```

## Write log

If you set `--write-log` option, user's writes in the TTY can be Logged. for example:

if you run gotty like this:

```shell
./gotty -w --write-log --permit-arguments ./test.sh
```

this is `test.sh`:

```sh
#!/bin/bash

echo "Welcome: $4"
kubectl -n $1 exec -it $2 -c $3 -- sh
```

visit `http://127.0.0.1:8080/?arg=without-istio&arg=sleep-7b6d569576-57sjq&arg=sleep&arg=21001713` and input your commands in shell, and you will see user's writes in the log (operation logs):

```
...
2022/12/13 13:36:46 [write-log] map[arg:[without-istio sleep-7b6d569576-8nz7t sleep 21001713]] ls[CR]
2022/12/13 13:37:25 [write-log] map[arg:[without-istio sleep-7b6d569576-8nz7t sleep 21001713]] echo[SPACE]hello[SPACE]world;[CR]
2022/12/13 13:47:00 [write-log] map[arg:[without-istio sleep-7b6d569576-8nz7t sleep 21001713]] [CUU][CR]
...
```

Using the `[write-log]` flag, you can collect and store these logs persistently. All args are in the log, including the userID.

## Development

You can build a binary by simply running `make`. go1.16 is required.
Expand Down
4 changes: 4 additions & 0 deletions server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,14 @@ func (server *Server) processWSConn(ctx context.Context, conn *websocket.Conn, h

opts := []webtty.Option{
webtty.WithWindowTitle(titleBuf.Bytes()),
webtty.WithArguments(params),
}
if server.options.PermitWrite {
opts = append(opts, webtty.WithPermitWrite())
}
if server.options.WriteLog {
opts = append(opts, webtty.WithWriteLog())
}
if server.options.EnableReconnect {
opts = append(opts, webtty.WithReconnect(server.options.ReconnectTime))
}
Expand Down
1 change: 1 addition & 0 deletions server/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type Options struct {
Port string `hcl:"port" flagName:"port" flagSName:"p" flagDescribe:"Port number to liten" default:"8080"`
Path string `hcl:"path" flagName:"path" flagSName:"m" flagDescribe:"Base path" default:"/"`
PermitWrite bool `hcl:"permit_write" flagName:"permit-write" flagSName:"w" flagDescribe:"Permit clients to write to the TTY (BE CAREFUL)" default:"false"`
WriteLog bool `hcl:"write_log" flagName:"write-log" flagDescribe:"Log user's writes in the TTY" default:"false"`
EnableBasicAuth bool `hcl:"enable_basic_auth" default:"false"`
Credential string `hcl:"credential" flagName:"credential" flagSName:"c" flagDescribe:"Credential for Basic Authentication (ex: user:pass, default disabled)" default:""`
EnableRandomUrl bool `hcl:"enable_random_url" flagName:"random-url" flagSName:"r" flagDescribe:"Add a random string to the URL" default:"false"`
Expand Down
114 changes: 114 additions & 0 deletions utils/log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package utils

import (
"fmt"
"regexp"
)

var ControlCodes = map[byte]string{
0: "NUL",
1: "SOH",
2: "STX",
3: "ETX",
4: "EOT",
5: "ENQ",
6: "ACK",
7: "BEL",
8: "BS",
9: "HT",
10: "LF",
11: "VT",
12: "FF",
13: "CR",
14: "SO",
15: "SI",
16: "DLE",
17: "DCI",
18: "DC2",
19: "DC3",
20: "DC4",
21: "NAK",
22: "SYN",
23: "TB",
24: "CAN",
25: "EM",
26: "SUB",
27: "ESC",
28: "FS",
29: "GS",
30: "RS",
31: "US",
32: "SPACE",
127: "DEL",
}

// https://en.wikipedia.org/wiki/ANSI_escape_code
// https://xtermjs.org/docs/api/vtfeatures/
var ControlSequences = map[string]string{
// Cursor Up
"ESC[A": "CUU",
// Cursor Down
"ESC[B": "CUD",
// Cursor Forward
"ESC[C": "CUF",
// Cursor Back
"ESC[D": "CUB",
}

var ControlSequencePatterns = map[string]string{
// Device Status Report
// Reports the cursor position (CPR) by transmitting `ESC[n;mR`, where n is the row and m is the column.
"^ESC\\[\\d+;\\d+R$": "",
}

func ControlCodesToStr(codes []byte) (str string) {
for _, code := range codes {
if value, ok := ControlCodes[code]; ok {
str += value
} else {
str += string(code)
}
}
llaoj marked this conversation as resolved.
Show resolved Hide resolved
return
}

func ControlCodesToEscapedStr(codes []byte) (str string) {
for _, code := range codes {
if value, ok := ControlCodes[code]; ok {
str += fmt.Sprintf("[%s]", value)
} else if code == 91 || code == 92 || code == 93 {
// escaping [ ] \
str += fmt.Sprintf("\\%s", string(code))
} else {
str += string(code)
}
}
return
}

func ControlSequenceToStr(codes []byte) (string, bool) {
sequence := ControlCodesToStr(codes)
for key, value := range ControlSequences {
if key == sequence {
return fmt.Sprintf("[%s]", value), true
}
}
llaoj marked this conversation as resolved.
Show resolved Hide resolved

for key, value := range ControlSequencePatterns {
if regexp.MustCompile(key).Match([]byte(sequence)) {
return value, true
}
}
return sequence, false
}

func FormatWriteLog(codes []byte, line *string) {
n := len(codes)
if n >= 3 {
if str, exists := ControlSequenceToStr(codes); exists {
*line += str
return
}
}
*line += ControlCodesToEscapedStr(codes)
}
16 changes: 16 additions & 0 deletions webtty/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ func WithPermitWrite() Option {
}
}

// WithWriteLog sets a WebTTY to log user's writes in the TTY.
func WithWriteLog() Option {
return func(wt *WebTTY) error {
wt.writeLog = true
return nil
}
}

// WithFixedColumns sets a fixed width to TTY master.
func WithFixedColumns(columns int) Option {
return func(wt *WebTTY) error {
Expand All @@ -41,6 +49,14 @@ func WithWindowTitle(windowTitle []byte) Option {
}
}

// WithArguments sets the command line arguments that clients send
func WithArguments(arguments map[string][]string) Option {
return func(wt *WebTTY) error {
wt.arguments = arguments
return nil
}
}

// WithReconnect enables reconnection on the master side.
func WithReconnect(timeInSeconds int) Option {
return func(wt *WebTTY) error {
Expand Down
19 changes: 17 additions & 2 deletions webtty/webtty.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"encoding/base64"
"encoding/json"
"github.com/sorenisanerd/gotty/utils"
"log"
"sync"

"github.com/pkg/errors"
Expand All @@ -19,7 +21,9 @@ type WebTTY struct {
slave Slave

windowTitle []byte
arguments map[string][]string
permitWrite bool
writeLog bool
columns int
rows int
reconnect int // in seconds
Expand Down Expand Up @@ -93,13 +97,14 @@ func (wt *WebTTY) Run(ctx context.Context) error {
go func() {
errs <- func() error {
buffer := make([]byte, wt.bufferSize)
var line string
for {
n, err := wt.masterConn.Read(buffer)
if err != nil {
return ErrMasterClosed
}

err = wt.handleMasterReadEvent(buffer[:n])
err = wt.handleMasterReadEvent(buffer[:n], &line)
if err != nil {
return err
}
Expand Down Expand Up @@ -168,7 +173,7 @@ func (wt *WebTTY) masterWrite(data []byte) error {
return nil
}

func (wt *WebTTY) handleMasterReadEvent(data []byte) error {
func (wt *WebTTY) handleMasterReadEvent(data []byte, line *string) error {
if len(data) == 0 {
return errors.New("unexpected zero length read from master")
}
Expand All @@ -189,6 +194,16 @@ func (wt *WebTTY) handleMasterReadEvent(data []byte) error {
return errors.Wrapf(err, "failed to decode received data")
}

if wt.writeLog {
utils.FormatWriteLog(decodedBuffer[:n], line)
// 13(ASCII) means carriage return(CR)
// it is the end of a line
if decodedBuffer[n-1] == 13 {
log.Printf("[write-log] %v %s\n", wt.arguments, *line)
*line = ""
}
}

_, err = wt.slave.Write(decodedBuffer[:n])
if err != nil {
return errors.Wrapf(err, "failed to write received data to slave")
Expand Down