feat(backend): Add Redis protocol support

This commit is contained in:
pycook
2025-05-12 00:48:07 +08:00
parent 6c8b1fc942
commit 48160e008d
7 changed files with 274 additions and 164 deletions

View File

@@ -4,19 +4,23 @@ go 1.21.3
require (
github.com/BurntSushi/toml v1.4.0
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v0.19.0
github.com/charmbracelet/bubbletea v0.27.1
github.com/charmbracelet/lipgloss v0.13.0
github.com/creack/pty v1.1.24
github.com/fatih/color v1.17.0
github.com/getwe/figlet4go v0.0.0-20160909034824-bc879344e874
github.com/gin-gonic/gin v1.10.0
github.com/go-resty/resty/v2 v2.14.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-runewidth v0.0.16
github.com/nicksnyder/go-i18n/v2 v2.4.0
github.com/oklog/run v1.1.0
github.com/pkg/sftp v1.13.6
github.com/redis/go-redis/v9 v9.6.1
github.com/rivo/uniseg v0.4.7
github.com/samber/lo v1.47.0
github.com/spf13/cast v1.7.0
github.com/spf13/pflag v1.0.5
@@ -30,20 +34,19 @@ require (
golang.org/x/text v0.17.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.11
gorm.io/plugin/soft_delete v1.2.1
)
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/x/ansi v0.1.4 // indirect
github.com/charmbracelet/x/input v0.1.0 // indirect
github.com/charmbracelet/x/term v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.1.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/golang/snappy v0.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
@@ -51,13 +54,11 @@ require (
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
gorm.io/driver/postgres v1.5.11 // indirect
golang.org/x/term v0.23.0 // indirect
)
require (

View File

@@ -41,6 +41,8 @@ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -91,8 +93,6 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -169,7 +169,6 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM=
github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
@@ -311,7 +310,6 @@ golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -338,8 +336,9 @@ google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFW
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=

View File

@@ -181,6 +181,7 @@ func DoConnect(ctx *gin.Context, ws *websocket.Conn) (sess *gsession.Session, er
if !sess.IsGuacd() {
w, h := cast.ToInt(ctx.Query("w")), cast.ToInt(ctx.Query("h"))
sess.SshParser = gsession.NewParser(sess.SessionId, w, h)
sess.SshParser.Protocol = sess.Protocol
sessionService := service.NewSessionService()
cmds, err := sessionService.GetSshParserCommands(ctx, []int(asset.AccessAuth.CmdIds))
@@ -238,7 +239,5 @@ func DoConnect(ctx *gin.Context, ws *websocket.Conn) (sess *gsession.Session, er
// This is an external function in authorization.go, but needed here to avoid circular imports
func hasAuthorization(ctx *gin.Context, sess *gsession.Session) bool {
// Implementation handled externally by the imported authorization package
// This is just a placeholder to satisfy the compiler
return true // Always return true for now, will be handled by proper authorization checks
return service.DefaultAuthService.HasAuthorization(ctx, sess)
}

View File

@@ -2,28 +2,25 @@ package connect
import (
"bufio"
"bytes"
"database/sql"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
"sync/atomic"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/creack/pty"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"github.com/samber/lo"
"github.com/spf13/cast"
mysqlDriver "gorm.io/driver/mysql"
"gorm.io/gorm"
"go.uber.org/zap"
"github.com/veops/oneterm/internal/model"
gsession "github.com/veops/oneterm/internal/session"
"github.com/veops/oneterm/internal/tunneling"
"github.com/veops/oneterm/pkg/logger"
)
// connectOther connects to other protocols (MySQL, Redis, etc.)
// connectOther connects to other protocols (Redis, MySQL, etc.)
func connectOther(ctx *gin.Context, sess *gsession.Session, asset *model.Asset, account *model.Account, gateway *model.Gateway) (err error) {
chs := sess.Chans
defer func() {
@@ -32,138 +29,189 @@ func connectOther(ctx *gin.Context, sess *gsession.Session, asset *model.Asset,
}
}()
protocol := strings.Split(sess.Protocol, ":")[0]
ip, port, err := tunneling.Proxy(false, sess.SessionId, protocol, asset, gateway)
if err != nil {
return
}
// Handle Redis connections
if sess.IsRedis() {
logger.L().Info("Starting Redis connection", zap.String("sessionId", sess.SessionId))
var (
rdb *redis.Client
db *gorm.DB
)
switch protocol {
case "redis":
rdb = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", ip, port),
Password: account.Password,
DialTimeout: time.Second,
// Setup proxy and connection parameters
protocol := strings.Split(sess.Protocol, ":")[0]
ip, port, err := tunneling.Proxy(false, sess.SessionId, protocol, asset, gateway)
if err != nil {
logger.L().Error("Failed to setup tunnel", zap.Error(err))
return err
}
// Build redis-cli command
args := []string{"-h", ip, "-p", fmt.Sprintf("%d", port)}
if account.Password != "" {
args = append(args, "-a", account.Password)
}
logger.L().Info("Starting redis-cli", zap.String("host", ip), zap.Int("port", port))
// Create command and pseudo-terminal
cmd := exec.CommandContext(sess.Gctx, "redis-cli", args...)
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
ptmx, err := pty.Start(cmd)
if err != nil {
logger.L().Error("Failed to start redis-cli with pty", zap.Error(err))
return fmt.Errorf("failed to start redis-cli: %w", err)
}
// Set standard terminal size
_ = pty.Setsize(ptmx, &pty.Winsize{
Cols: 80,
Rows: 24,
})
_, err = rdb.Ping(ctx).Result()
if err != nil {
return
}
case "mysql":
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/?charset=utf8mb4&parseTime=True&loc=Local", account.Account, account.Password, ip, port)
db, err = gorm.Open(mysqlDriver.Open(dsn))
if err != nil {
return
}
}
chs.ErrChan <- err
// Simplified IO channel setup - direct connection
chs.Rin, chs.Win = io.Pipe()
sess.G.Go(func() error {
reader := bufio.NewReader(chs.Rin)
buf := &bytes.Buffer{}
pt := ""
ss := strings.Split(sess.Protocol, ":")
if len(ss) == 2 {
pt = ss[1]
}
sess.Prompt = fmt.Sprintf("%s@%s:%s> ", account.Account, asset.Name, pt)
chs.OutChan <- append(byteRN, []byte(sess.Prompt)...)
for {
select {
case <-sess.Gctx.Done():
return nil
default:
rn, size, err := reader.ReadRune()
if err != nil {
return err
}
if size <= 0 || rn == utf8.RuneError {
continue
}
p := make([]byte, utf8.RuneLen(rn))
utf8.EncodeRune(p, rn)
p = bytes.ReplaceAll(p, byteT, byteS)
for bytes.HasSuffix(p, byteDel) {
p = p[:len(p)-1]
if buf.Len() > 0 {
var dels []byte
last, ok := lo.Last([]rune(buf.String()))
for i := 0; ok && i < lipgloss.Width(string(last)); i++ {
dels = append(dels, byteClearCur...)
}
chs.OutChan <- dels
buf.Truncate(buf.Len() - len([]byte(string(last))))
}
}
if len(p) <= 0 {
continue
}
chs.OutChan <- p
buf.Write(p)
bs := buf.Bytes()
if idx := bytes.LastIndex(bs, byteClearAll); idx >= 0 {
buf.Reset()
continue
}
if idx := bytes.LastIndex(bs, byteR); idx < 0 {
continue
}
bs = bs[:len(bs)-1]
if bytes.Equal(bs, []byte("exit")) {
sess.Once.Do(func() { close(chs.AwayChan) })
// Create a reader to read PTY output
ptmxReader := bufio.NewReader(ptmx)
// Add an atomic variable to track if exit message has been sent
var exitMessageSent int32
// Monitor process exit
sess.G.Go(func() error {
err := cmd.Wait()
logger.L().Info("Redis cli process exited", zap.Error(err))
// Only send termination message if not already sent
if atomic.CompareAndSwapInt32(&exitMessageSent, 0, 1) {
// Send termination message
terminationMsg := "\r\n\033[31mThe connection is closed!\033[0m\r\n"
chs.OutBuf.WriteString(terminationMsg)
}
sess.Once.Do(func() {
logger.L().Info("Closing AwayChan from Redis process monitor")
close(chs.AwayChan)
})
return fmt.Errorf("redis-cli process terminated: %w", err)
})
// Goroutine 1: Process input, detect exit command
sess.G.Go(func() error {
defer ptmx.Close()
buf := make([]byte, 1024)
var inputBuffer string
for {
select {
case <-sess.Gctx.Done():
return nil
}
buf.Reset()
var (
res any
rows *sql.Rows
)
if len(bs) > 0 {
switch protocol {
case "redis":
parts := lo.Map(reRedis.FindAllString(string(bs), -1), func(p string, _ int) any { return p })
res, err = rdb.Do(ctx, parts...).Result()
case "mysql":
if rows, err = db.WithContext(ctx).Raw(string(bs)).Rows(); err == nil {
heads, _ := rows.Columns()
n := len(heads)
rs := make([][]string, 0)
for rows.Next() {
r := make([]any, n)
r = lo.Map(r, func(v any, _ int) any { return new(any) })
if err = rows.Scan(r...); err != nil {
default:
n, err := chs.Rin.Read(buf)
if err != nil {
if err == io.EOF {
return nil
}
return err
}
if n > 0 {
input := string(buf[:n])
// Accumulate user input to detect complete commands
if input != "\r" {
// Check if all characters are printable
allPrintable := true
for _, ch := range input {
if ch < 32 || ch > 126 {
allPrintable = false
break
}
rs = append(rs, lo.Map(r, func(v any, i int) string { return cast.ToString(v) }))
}
res = strings.ReplaceAll(table.New().Border(border).Headers(heads...).Rows(rs...).String(), "\n", "\r\n")
if allPrintable {
inputBuffer += input
}
}
// Detect command end (enter key)
if input == "\r" {
processCmd := strings.TrimSpace(inputBuffer)
// Check for exit command
if strings.EqualFold(processCmd, "exit") || strings.EqualFold(processCmd, "quit") {
// Send command to Redis CLI for normal exit
if _, err := ptmx.Write(buf[:n]); err != nil {
return err
}
// Mark exit message as sent, but don't send it here. Let the process exit handler send it.
atomic.StoreInt32(&exitMessageSent, 1)
inputBuffer = ""
continue
}
// Reset command buffer
inputBuffer = ""
}
// Forward input to Redis CLI
if _, err := ptmx.Write(buf[:n]); err != nil {
return err
}
}
}
chs.OutChan <- []byte(fmt.Sprintf("\n%s\r\n%s", lo.Ternary[any](err == nil, lo.Ternary(res == nil, "", res), err), sess.Prompt))
err = nil
}
}
})
sess.G.Go(func() (err error) {
for {
select {
case <-sess.Gctx.Done():
return
case <-chs.AwayChan:
return
case <-chs.WindowChan:
continue
})
// Goroutine 2: Read Redis CLI output and send to OutChan
sess.G.Go(func() error {
for {
select {
case <-sess.Gctx.Done():
return nil
default:
rn, size, err := ptmxReader.ReadRune()
if err != nil {
if err == io.EOF {
return nil
}
return err
}
if size <= 0 || rn == utf8.RuneError {
continue
}
p := make([]byte, utf8.RuneLen(rn))
utf8.EncodeRune(p, rn)
// Send to OutChan for HandleTerm processing
chs.OutChan <- p
}
}
}
})
})
sess.G.Wait()
// Goroutine 3: Handle window size changes
sess.G.Go(func() error {
for {
select {
case <-sess.Gctx.Done():
return nil
case <-chs.AwayChan:
return fmt.Errorf("away")
case window := <-chs.WindowChan:
// Adjust Redis terminal size
_ = pty.Setsize(ptmx, &pty.Winsize{
Cols: uint16(window.Width),
Rows: uint16(window.Height),
})
return
// Adjust parser size
if sess.SshParser != nil {
sess.SshParser.Resize(window.Width, window.Height)
}
}
}
})
// Notify connection is ready
chs.ErrChan <- nil
return nil
}
return fmt.Errorf("unsupported protocol: %s", sess.Protocol)
}

View File

@@ -135,12 +135,12 @@ func connectSsh(ctx *gin.Context, sess *gsession.Session, asset *model.Asset, ac
// HandleTerm handles terminal sessions
func HandleTerm(sess *gsession.Session) (err error) {
defer func() {
logger.L().Debug("defer HandleSsh", zap.String("sessionId", sess.SessionId))
logger.L().Debug("defer HandleTerm", zap.String("sessionId", sess.SessionId))
sess.SshParser.Close(sess.Prompt)
sess.Status = model.SESSIONSTATUS_OFFLINE
sess.ClosedAt = lo.ToPtr(time.Now())
if err = gsession.UpsertSession(sess); err != nil {
logger.L().Error("offline ssh session failed", zap.String("sessionId", sess.SessionId), zap.Error(err))
logger.L().Error("offline session failed", zap.String("sessionId", sess.SessionId), zap.Error(err))
return
}
}()

View File

@@ -3,12 +3,10 @@ package connect
import (
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/nicksnyder/go-i18n/v2/i18n"
@@ -33,16 +31,8 @@ var (
},
}
byteClearAll = []byte("\x15\r")
byteClearCur = []byte("\b\x1b[J")
byteDel = []byte{'\x7f'}
byteR = []byte{'\r'}
byteN = []byte{'\n'}
byteT = []byte{'\t'}
byteS = []byte{' '}
byteRN = append(byteR, byteN...)
reRedis = regexp.MustCompile(`("[^"]*"|'[^']*'|\S+)`)
border = lipgloss.RoundedBorder()
wsWriteMutex = &sync.Mutex{}
)
// WriteToMonitors sends data to all monitoring sessions
@@ -117,6 +107,19 @@ func OfflineSession(ctx *gin.Context, sessionId string, closer string) {
// HandleError handles errors from sessions
func HandleError(ctx *gin.Context, sess *gsession.Session, err error, ws *websocket.Conn, chs *gsession.SessionChans) {
defer func() {
defer func() {
if r := recover(); r != nil {
logger.L().Error("Recovered from panic in HandleError",
zap.Any("panic", r),
zap.String("sessionId", func() string {
if sess != nil {
return sess.SessionId
}
return ""
}()))
}
}()
if sess == nil || sess.Chans == nil {
return
}
@@ -124,7 +127,12 @@ func HandleError(ctx *gin.Context, sess *gsession.Session, err error, ws *websoc
if chs != nil {
ch = chs.AwayChan
}
sess.Once.Do(func() { close(ch) })
sess.Once.Do(func() {
logger.L().Debug("Closing AwayChan from HandleError",
zap.String("sessionId", sess.SessionId))
close(ch)
})
}()
if err == nil {
@@ -150,19 +158,24 @@ func WriteErrMsg(sess *gsession.Session, msg string) {
}
// Write writes data to the session output
func Write(sess *gsession.Session) (err error) {
// skipRecording: If true, it will skip recording to avoid duplicate recordings of manually recorded content
func Write(sess *gsession.Session, skipRecording ...bool) (err error) {
chs := sess.Chans
out := chs.OutBuf.Bytes()
if sess.SessionType == model.SESSIONTYPE_WEB && sess.Ws != nil {
if len(out) > 0 || sess.IsGuacd() {
wsWriteMutex.Lock()
defer wsWriteMutex.Unlock()
err = sess.Ws.WriteMessage(websocket.TextMessage, out)
}
} else if sess.SessionType == model.SESSIONTYPE_CLIENT && len(out) > 0 {
_, err = sess.CliRw.Write(out)
}
if sess.SshRecoder != nil && len(out) > 0 && !sess.IsGuacd() {
// Only write to recording if skipRecording is not specified or explicitly set to false
shouldSkip := len(skipRecording) > 0 && skipRecording[0]
if sess.SshRecoder != nil && len(out) > 0 && !sess.IsGuacd() && !shouldSkip {
sess.SshRecoder.Write(out)
}

View File

@@ -57,6 +57,7 @@ type Parser struct {
Input []byte
Output []byte
SessionId string
Protocol string
Cmds []*model.Command
isPrompt bool
prompt string
@@ -82,19 +83,49 @@ func (p *Parser) AddInput(bs []byte) (cmd string, forbidden bool) {
p.lastCmd = ""
p.lastRes = ""
}
// Track command input, directly use curCmd field
if len(bs) > 0 {
if bytes.Equal(bs, []byte("\r")) {
// Command ends, keep curCmd unchanged
} else if len(bs) == 1 && (bs[0] == '\b' || bs[0] == 127) { // Backspace key
if len(p.curCmd) > 0 {
p.curCmd = p.curCmd[:len(p.curCmd)-1]
}
} else {
// Check if all characters are printable
input := string(bs)
allPrintable := true
for _, ch := range input {
if ch < 32 || ch > 126 {
allPrintable = false
break
}
}
if allPrintable {
p.curCmd += input
}
}
}
p.Input = append(p.Input, bs...)
if !bytes.HasSuffix(p.Input, []byte("\r")) {
return
}
p.isPrompt = true
p.curCmd = p.getCmdLocked()
// Save current command
currentCmd := strings.TrimSpace(p.curCmd)
// Reset command buffer
p.curCmd = ""
p.resetLocked()
filter := ""
if filter, forbidden = p.IsForbidden(p.curCmd); forbidden {
if filter, forbidden = p.IsForbidden(currentCmd); forbidden {
cmd = filter
return
}
p.lastCmd = p.curCmd
p.lastCmd = currentCmd
return
}
@@ -117,7 +148,7 @@ func (p *Parser) IsForbidden(cmd string) (string, bool) {
}
func (p *Parser) WriteDb() {
if p.lastCmd == "" {
if p.lastCmd == "" || strings.TrimSpace(p.lastCmd) == "" {
return
}
m := &model.SessionCmd{
@@ -165,6 +196,12 @@ func (p *Parser) GetCmd() string {
}
func (p *Parser) getCmdLocked() string {
// If the current command being built is not empty, return it
if p.curCmd != "" {
return p.curCmd
}
// Otherwise extract from output
s := p.getOutputLocked()
// TODO: some promot may change with its dir
return strings.TrimPrefix(s, p.prompt)
@@ -209,7 +246,20 @@ func (p *Parser) getOutputLocked() string {
p.lastRes = ""
if ln > 1 {
p.lastRes = strings.Join(res[:ln-1], "\n")
// Process result based on protocol type
if strings.HasPrefix(p.Protocol, "ssh") {
// For SSH sessions, keep the original logic, retain all lines
p.lastRes = strings.Join(res[:ln-1], "\n")
} else {
// For non-SSH sessions (like Redis, MySQL), remove first and last line
startIdx := 1
endIdx := ln - 1
// Ensure there are enough lines to process
if ln > 2 {
p.lastRes = strings.Join(res[startIdx:endIdx], "\n")
}
}
}
p.curRes = res[ln-1]
return p.curRes