diff --git a/backend/app/api/v1/terminal.go b/backend/app/api/v1/terminal.go index eb7c9c8a8..6966b2d2b 100644 --- a/backend/app/api/v1/terminal.go +++ b/backend/app/api/v1/terminal.go @@ -79,6 +79,48 @@ func (b *BaseApi) WsSsh(c *gin.Context) { } } +func (b *BaseApi) LocalWsSsh(c *gin.Context) { + cols, err := strconv.Atoi(c.DefaultQuery("cols", "80")) + if err != nil { + helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err) + return + } + rows, err := strconv.Atoi(c.DefaultQuery("rows", "40")) + if err != nil { + helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err) + return + } + + wsConn, err := upGrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + global.LOG.Errorf("gin context http handler failed, err: %v", err) + return + } + defer wsConn.Close() + + slave, err := terminal.NewCommand() + if wshandleError(wsConn, err) { + return + } + defer slave.Close() + + tty, err := terminal.NewLocalWsSession(cols, rows, wsConn, slave) + if wshandleError(wsConn, err) { + return + } + + quitChan := make(chan bool, 3) + tty.Start(quitChan) + go slave.Wait(quitChan) + + <-quitChan + + global.LOG.Info("websocket finished") + if wshandleError(wsConn, err) { + return + } +} + func wshandleError(ws *websocket.Conn, err error) bool { if err != nil { global.LOG.Errorf("handler ws faled:, err: %v", err) diff --git a/backend/app/dto/host.go b/backend/app/dto/host.go index d6b192d6c..c64017428 100644 --- a/backend/app/dto/host.go +++ b/backend/app/dto/host.go @@ -3,7 +3,7 @@ package dto import "time" type HostCreate struct { - Name string `json:"name" validate:"required,name"` + Name string `json:"name" validate:"required"` Addr string `json:"addr" validate:"required,ip"` Port uint `json:"port" validate:"required,number,max=65535,min=1"` User string `json:"user" validate:"required"` @@ -27,7 +27,7 @@ type HostInfo struct { } type HostUpdate struct { - Name string `json:"name" validate:"required,name"` + Name string `json:"name" validate:"required"` Addr string `json:"addr" validate:"required,ip"` Port uint `json:"port" validate:"required,number,max=65535,min=1"` User string `json:"user" validate:"required"` diff --git a/backend/go.mod b/backend/go.mod index 17d77b8fc..f17e4b6d9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -15,6 +15,7 @@ require ( github.com/gorilla/websocket v1.5.0 github.com/gwatts/gin-adapter v1.0.0 github.com/jinzhu/copier v0.3.5 + github.com/kr/pty v1.1.1 github.com/mojocn/base64Captcha v1.3.5 github.com/natefinch/lumberjack v2.0.0+incompatible github.com/nicksnyder/go-i18n/v2 v2.1.2 diff --git a/backend/go.sum b/backend/go.sum index 03f708665..951ca346a 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -257,6 +257,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/backend/init/binary/gotty.go b/backend/init/binary/gotty.go deleted file mode 100644 index ebe48bbed..000000000 --- a/backend/init/binary/gotty.go +++ /dev/null @@ -1,23 +0,0 @@ -package binary - -import ( - "io" - "os" - "os/exec" - - "github.com/1Panel-dev/1Panel/global" -) - -func StartTTY() { - cmd := "gotty" - params := []string{"--permit-write", "bash"} - go func() { - c := exec.Command(cmd, params...) - c.Env = append(c.Env, os.Environ()...) - c.Stdout = io.Discard - c.Stderr = io.Discard - if err := c.Run(); err != nil { - global.LOG.Error(err) - } - }() -} diff --git a/backend/middleware/operation.go b/backend/middleware/operation.go index 8d6310161..2b621fde9 100644 --- a/backend/middleware/operation.go +++ b/backend/middleware/operation.go @@ -2,8 +2,10 @@ package middleware import ( "bytes" + "encoding/json" "io/ioutil" "net/http" + "net/url" "strings" "time" @@ -16,17 +18,31 @@ import ( func OperationRecord() gin.HandlerFunc { return func(c *gin.Context) { var body []byte - if c.Request.Method == http.MethodGet || strings.Contains(c.Request.URL.Path, "search") { + if strings.Contains(c.Request.URL.Path, "search") { c.Next() return } - var err error - body, err = ioutil.ReadAll(c.Request.Body) - if err != nil { - global.LOG.Errorf("read body from request failed, err: %v", err) + if c.Request.Method == http.MethodGet { + query := c.Request.URL.RawQuery + query, _ = url.QueryUnescape(query) + split := strings.Split(query, "&") + m := make(map[string]string) + for _, v := range split { + kv := strings.Split(v, "=") + if len(kv) == 2 { + m[kv[0]] = kv[1] + } + } + body, _ = json.Marshal(&m) } else { - c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + var err error + body, err = ioutil.ReadAll(c.Request.Body) + if err != nil { + global.LOG.Errorf("read body from request failed, err: %v", err) + } else { + c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + } } pathInfo := loadLogInfo(c.Request.URL.Path) diff --git a/backend/router/ro_terminal.go b/backend/router/ro_terminal.go index 4b2f8ea4a..caa28f1a4 100644 --- a/backend/router/ro_terminal.go +++ b/backend/router/ro_terminal.go @@ -2,7 +2,6 @@ package router import ( v1 "github.com/1Panel-dev/1Panel/app/api/v1" - "github.com/1Panel-dev/1Panel/middleware" "github.com/gin-gonic/gin" ) @@ -11,9 +10,9 @@ type TerminalRouter struct{} func (s *UserRouter) InitTerminalRouter(Router *gin.RouterGroup) { terminalRouter := Router.Group("terminals") - withRecordRouter := terminalRouter.Use(middleware.OperationRecord()) baseApi := v1.ApiGroupApp.BaseApi { - withRecordRouter.GET("", baseApi.WsSsh) + terminalRouter.GET("", baseApi.WsSsh) + terminalRouter.GET("/local", baseApi.LocalWsSsh) } } diff --git a/backend/server/server.go b/backend/server/server.go index 784f55989..3f9c4994e 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -3,13 +3,13 @@ package server import ( "encoding/gob" "fmt" + "time" + "github.com/1Panel-dev/1Panel/init/cache" "github.com/1Panel-dev/1Panel/init/session" "github.com/1Panel-dev/1Panel/init/session/psession" - "time" "github.com/1Panel-dev/1Panel/global" - "github.com/1Panel-dev/1Panel/init/binary" "github.com/1Panel-dev/1Panel/init/db" "github.com/1Panel-dev/1Panel/init/log" "github.com/1Panel-dev/1Panel/init/migration" @@ -30,7 +30,6 @@ func Start() { gob.Register(psession.SessionUser{}) cache.Init() session.Init() - binary.StartTTY() gin.SetMode(global.CONF.System.Level) routers := router.Routers() diff --git a/backend/utils/terminal/local_cmd.go b/backend/utils/terminal/local_cmd.go new file mode 100644 index 000000000..5b9042c0a --- /dev/null +++ b/backend/utils/terminal/local_cmd.go @@ -0,0 +1,115 @@ +package terminal + +import ( + "os" + "os/exec" + "syscall" + "time" + "unsafe" + + "github.com/1Panel-dev/1Panel/global" + "github.com/kr/pty" + "github.com/pkg/errors" +) + +const ( + DefaultCloseSignal = syscall.SIGINT + DefaultCloseTimeout = 10 * time.Second +) + +type LocalCommand struct { + command string + + closeSignal syscall.Signal + closeTimeout time.Duration + + cmd *exec.Cmd + pty *os.File + ptyClosed chan struct{} +} + +func NewCommand() (*LocalCommand, error) { + command := "sh" + cmd := exec.Command(command) + cmd.Dir = "/" + + pty, err := pty.Start(cmd) + if err != nil { + return nil, errors.Wrapf(err, "failed to start command `%s`", command) + } + ptyClosed := make(chan struct{}) + + lcmd := &LocalCommand{ + command: command, + closeSignal: DefaultCloseSignal, + closeTimeout: DefaultCloseTimeout, + + cmd: cmd, + pty: pty, + ptyClosed: ptyClosed, + } + + return lcmd, nil +} + +func (lcmd *LocalCommand) Read(p []byte) (n int, err error) { + return lcmd.pty.Read(p) +} + +func (lcmd *LocalCommand) Write(p []byte) (n int, err error) { + return lcmd.pty.Write(p) +} + +func (lcmd *LocalCommand) Close() error { + if lcmd.cmd != nil && lcmd.cmd.Process != nil { + _ = lcmd.cmd.Process.Signal(lcmd.closeSignal) + } + for { + select { + case <-lcmd.ptyClosed: + return nil + case <-lcmd.closeTimeoutC(): + _ = lcmd.cmd.Process.Signal(syscall.SIGKILL) + } + } +} + +func (lcmd *LocalCommand) ResizeTerminal(width int, height int) error { + window := struct { + row uint16 + col uint16 + x uint16 + y uint16 + }{ + uint16(height), + uint16(width), + 0, + 0, + } + _, _, errno := syscall.Syscall( + syscall.SYS_IOCTL, + lcmd.pty.Fd(), + syscall.TIOCSWINSZ, + uintptr(unsafe.Pointer(&window)), + ) + if errno != 0 { + return errno + } else { + return nil + } +} + +func (lcmd *LocalCommand) Wait(quitChan chan bool) { + if err := lcmd.cmd.Wait(); err != nil { + global.LOG.Errorf("ssh session wait failed, err: %v", err) + setQuit(quitChan) + } +} + +func (lcmd *LocalCommand) closeTimeoutC() <-chan time.Time { + if lcmd.closeTimeout >= 0 { + return time.After(lcmd.closeTimeout) + } + + return make(chan time.Time) +} diff --git a/backend/utils/terminal/ws_local_session.go b/backend/utils/terminal/ws_local_session.go new file mode 100644 index 000000000..dc95838dc --- /dev/null +++ b/backend/utils/terminal/ws_local_session.go @@ -0,0 +1,108 @@ +package terminal + +import ( + "encoding/base64" + "encoding/json" + "sync" + + "github.com/1Panel-dev/1Panel/global" + "github.com/gorilla/websocket" + "github.com/pkg/errors" +) + +type LocalWsSession struct { + slave *LocalCommand + wsConn *websocket.Conn + + writeMutex sync.Mutex +} + +func NewLocalWsSession(cols, rows int, wsConn *websocket.Conn, slave *LocalCommand) (*LocalWsSession, error) { + if err := slave.ResizeTerminal(cols, rows); err != nil { + global.LOG.Errorf("ssh pty change windows size failed, err: %v", err) + } + + return &LocalWsSession{ + slave: slave, + wsConn: wsConn, + }, nil +} + +func (sws *LocalWsSession) Start(quitChan chan bool) { + go sws.handleSlaveEvent(quitChan) + go sws.receiveWsMsg(quitChan) +} + +func (sws *LocalWsSession) handleSlaveEvent(exitCh chan bool) { + defer setQuit(exitCh) + + buffer := make([]byte, 1024) + for { + select { + case <-exitCh: + return + default: + n, err := sws.slave.Read(buffer) + if err != nil { + global.LOG.Errorf("read buffer from slave failed, err: %v", err) + } + + err = sws.masterWrite(buffer[:n]) + if err != nil { + global.LOG.Errorf("handle master read event failed, err: %v", err) + } + } + } +} + +func (sws *LocalWsSession) masterWrite(data []byte) error { + sws.writeMutex.Lock() + defer sws.writeMutex.Unlock() + err := sws.wsConn.WriteMessage(websocket.TextMessage, data) + if err != nil { + return errors.Wrapf(err, "failed to write to master") + } + + return nil +} + +func (sws *LocalWsSession) receiveWsMsg(exitCh chan bool) { + wsConn := sws.wsConn + defer setQuit(exitCh) + for { + select { + case <-exitCh: + return + default: + _, wsData, err := wsConn.ReadMessage() + if err != nil { + global.LOG.Errorf("reading webSocket message failed, err: %v", err) + return + } + msgObj := wsMsg{} + if err := json.Unmarshal(wsData, &msgObj); err != nil { + global.LOG.Errorf("unmarshal websocket message %s failed, err: %v", wsData, err) + } + switch msgObj.Type { + case wsMsgResize: + if msgObj.Cols > 0 && msgObj.Rows > 0 { + if err := sws.slave.ResizeTerminal(msgObj.Cols, msgObj.Rows); err != nil { + global.LOG.Errorf("ssh pty change windows size failed, err: %v", err) + } + } + case wsMsgCmd: + decodeBytes, err := base64.StdEncoding.DecodeString(msgObj.Cmd) + if err != nil { + global.LOG.Errorf("websock cmd string base64 decoding failed, err: %v", err) + } + sws.sendWebsocketInputCommandToSshSessionStdinPipe(decodeBytes) + } + } + } +} + +func (sws *LocalWsSession) sendWebsocketInputCommandToSshSessionStdinPipe(cmdBytes []byte) { + if _, err := sws.slave.Write(cmdBytes); err != nil { + global.LOG.Errorf("ws cmd bytes write to ssh.stdin pipe failed, err: %v", err) + } +} diff --git a/backend/utils/terminal/ws_session.go b/backend/utils/terminal/ws_session.go index a6c04c178..4fb33a335 100644 --- a/backend/utils/terminal/ws_session.go +++ b/backend/utils/terminal/ws_session.go @@ -192,10 +192,6 @@ func (sws *LogicSshWsSession) Wait(quitChan chan bool) { } } -func (sws *LogicSshWsSession) LogString() string { - return sws.logBuff.buffer.String() -} - func setQuit(ch chan bool) { ch <- true } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3dac07b7e..72eea6473 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7244,6 +7244,11 @@ "resolved": "https://registry.npmjs.org/xterm-addon-attach/-/xterm-addon-attach-0.6.0.tgz", "integrity": "sha512-Mo8r3HTjI/EZfczVCwRU6jh438B4WLXxdFO86OB7bx0jGhwh2GdF4ifx/rP+OB+Cb2vmLhhVIZ00/7x3YSP3dg==" }, + "xterm-addon-fit": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz", + "integrity": "sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ==" + }, "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8e199e69c..58c41bb77 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,7 +40,8 @@ "vue-router": "^4.0.12", "vue3-seamless-scroll": "^1.2.0", "xterm": "^4.19.0", - "xterm-addon-attach": "^0.6.0" + "xterm-addon-attach": "^0.6.0", + "xterm-addon-fit": "^0.5.0" }, "devDependencies": { "@commitlint/cli": "^17.0.1", diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 2f3af2712..b632e0a85 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -92,6 +92,7 @@ export default { connHistory: 'historys', hostHistory: 'History record', addHost: 'Add Host', + localhost: 'Localhost', name: 'Name', port: 'Port', user: 'User', @@ -100,6 +101,7 @@ export default { keyMode: 'PrivateKey', password: 'Password', key: 'Private Key', + emptyTerminal: 'No terminal is currently connected', }, operations: { detail: { diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index b543227c8..d6f36e70a 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -93,6 +93,7 @@ export default { connHistory: '历史连接', hostHistory: '历史主机信息', addHost: '添加主机', + localhost: '本地服务器', name: '名称', port: '端口', user: '用户', @@ -101,6 +102,7 @@ export default { keyMode: '密钥输入', password: '密码', key: '密钥', + emptyTerminal: '暂无终端连接', }, operations: { detail: { diff --git a/frontend/src/views/terminal/index.vue b/frontend/src/views/terminal/index.vue index c12e03f10..15500bff8 100644 --- a/frontend/src/views/terminal/index.vue +++ b/frontend/src/views/terminal/index.vue @@ -12,7 +12,7 @@ v-model="terminalValue" @edit="handleTabsEdit" > - + -