diff --git a/app/http/controllers/ssh_controller.go b/app/http/controllers/ssh_controller.go new file mode 100644 index 00000000..5e57d1f7 --- /dev/null +++ b/app/http/controllers/ssh_controller.go @@ -0,0 +1,166 @@ +package controllers + +import ( + "bytes" + "context" + nethttp "net/http" + "sync" + "time" + + "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/facades" + "github.com/gorilla/websocket" + + "panel/app/models" + "panel/app/services" + "panel/pkg/ssh" +) + +type SshController struct { + AuthMethod ssh.AuthMethod + setting services.Setting +} + +func NewSshController() *SshController { + return &SshController{ + AuthMethod: ssh.PASSWORD, + setting: services.NewSettingImpl(), + } +} + +func (r *SshController) GetInfo(ctx http.Context) { + host := r.setting.Get(models.SettingKeySshHost) + port := r.setting.Get(models.SettingKeySshPort) + user := r.setting.Get(models.SettingKeySshUser) + password := r.setting.Get(models.SettingKeySshPassword) + if len(host) == 0 || len(user) == 0 || len(password) == 0 { + Error(ctx, http.StatusInternalServerError, "SSH 配置不完整") + return + } + + Success(ctx, http.Json{ + "host": host, + "port": port, + "user": user, + "password": password, + }) +} + +func (r *SshController) UpdateInfo(ctx http.Context) { + validator, err := ctx.Request().Validate(map[string]string{ + "host": "required", + "port": "required", + "user": "required", + "password": "required", + }) + if err != nil { + Error(ctx, http.StatusBadRequest, err.Error()) + return + } + if validator.Fails() { + Error(ctx, http.StatusBadRequest, validator.Errors().One()) + return + } + + host := ctx.Request().Input("host") + port := ctx.Request().Input("port") + user := ctx.Request().Input("user") + password := ctx.Request().Input("password") + err = r.setting.Set(models.SettingKeySshHost, host) + if err != nil { + facades.Log().Error("[面板][SSH] 更新配置失败 ", err) + Error(ctx, http.StatusInternalServerError, "系统内部错误") + return + } + err = r.setting.Set(models.SettingKeySshPort, port) + if err != nil { + facades.Log().Error("[面板][SSH] 更新配置失败 ", err) + Error(ctx, http.StatusInternalServerError, "系统内部错误") + return + } + err = r.setting.Set(models.SettingKeySshUser, user) + if err != nil { + facades.Log().Error("[面板][SSH] 更新配置失败 ", err) + Error(ctx, http.StatusInternalServerError, "系统内部错误") + return + } + err = r.setting.Set(models.SettingKeySshPassword, password) + if err != nil { + facades.Log().Error("[面板][SSH] 更新配置失败 ", err) + Error(ctx, http.StatusInternalServerError, "系统内部错误") + return + } + + Success(ctx, nil) +} + +func (r *SshController) Session(ctx http.Context) { + upGrader := websocket.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + CheckOrigin: func(r *nethttp.Request) bool { + return true + }, + Subprotocols: []string{ctx.Request().Header("Sec-WebSocket-Protocol")}, + } + + ws, err := upGrader.Upgrade(ctx.Response().Writer(), ctx.Request().Origin(), nil) + if err != nil { + facades.Log().Error("[面板][SSH] 建立连接失败 ", err) + Error(ctx, http.StatusInternalServerError, "系统内部错误") + return + } + defer ws.Close() + + var config *ssh.SSHClientConfig + config = ssh.SSHClientConfigPassword( + r.setting.Get(models.SettingKeySshHost)+":"+r.setting.Get(models.SettingKeySshPort), + r.setting.Get(models.SettingKeySshUser), + r.setting.Get(models.SettingKeySshPassword), + ) + + client, err := ssh.NewSSHClient(config) + if err != nil { + ws.WriteControl(websocket.CloseMessage, + []byte(err.Error()), time.Now().Add(time.Second)) + return + } + defer client.Close() + + turn, err := ssh.NewTurn(ws, client) + if err != nil { + ws.WriteControl(websocket.CloseMessage, + []byte(err.Error()), time.Now().Add(time.Second)) + return + } + defer turn.Close() + + var bufPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, + } + var logBuff = bufPool.Get().(*bytes.Buffer) + logBuff.Reset() + defer bufPool.Put(logBuff) + + ctx2, cancel := context.WithCancel(context.Background()) + wg := sync.WaitGroup{} + wg.Add(2) + go func() { + defer wg.Done() + err := turn.LoopRead(logBuff, ctx2) + if err != nil { + facades.Log().Error("[面板][SSH] 读取数据失败 ", err.Error()) + } + }() + go func() { + defer wg.Done() + err := turn.SessionWait() + if err != nil { + facades.Log().Error("[面板][SSH] 会话失败 ", err.Error()) + } + cancel() + }() + wg.Wait() +} diff --git a/app/http/controllers/website_controller.go b/app/http/controllers/website_controller.go index 92bb52d8..ae1458a6 100644 --- a/app/http/controllers/website_controller.go +++ b/app/http/controllers/website_controller.go @@ -55,7 +55,7 @@ func (c *WebsiteController) Add(ctx http.Context) { "name": "required|regex:^[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)*$", "domain": "required", "php": "required", - "db": "required", + "db": "bool", "db_type": "required_if:db,true", "db_name": "required_if:db,true", "db_user": "required_if:db,true", diff --git a/app/http/middleware/jwt.go b/app/http/middleware/jwt.go index b92860ab..1bd17f46 100644 --- a/app/http/middleware/jwt.go +++ b/app/http/middleware/jwt.go @@ -13,7 +13,7 @@ import ( // Jwt 确保通过 JWT 鉴权 func Jwt() http.Middleware { return func(ctx http.Context) { - token := ctx.Request().Header("access_token", ctx.Request().Input("access_token", "")) + token := ctx.Request().Header("access_token", ctx.Request().Input("access_token", ctx.Request().Header("Sec-WebSocket-Protocol"))) if len(token) == 0 { ctx.Request().AbortWithStatusJson(http.StatusUnauthorized, http.Json{ "code": 401, diff --git a/app/models/setting.go b/app/models/setting.go index 7d9cfa5f..fe39103e 100644 --- a/app/models/setting.go +++ b/app/models/setting.go @@ -10,6 +10,10 @@ const ( SettingKeyWebsitePath = "website_path" SettingKeyEntrance = "entrance" SettingKeyMysqlRootPassword = "mysql_root_password" + SettingKeySshHost = "ssh_host" + SettingKeySshPort = "ssh_port" + SettingKeySshUser = "ssh_user" + SettingKeySshPassword = "ssh_password" ) type Setting struct { diff --git a/go.mod b/go.mod index 18560307..d13a18ec 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,19 @@ module panel go 1.18 require ( + github.com/bytedance/sonic v1.9.2 github.com/gertd/go-pluralize v0.2.1 github.com/gin-contrib/static v0.0.1 github.com/gookit/color v1.5.4 github.com/goravel/framework v1.12.1-0.20230721095426-6f24ecdaf6a7 + github.com/gorilla/websocket v1.5.0 github.com/iancoleman/strcase v0.2.0 github.com/imroc/req/v3 v3.37.2 github.com/mojocn/base64Captcha v1.3.5 github.com/shirou/gopsutil v3.21.11+incompatible github.com/spf13/cast v1.5.1 github.com/stretchr/testify v1.8.4 + golang.org/x/crypto v0.11.0 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 ) @@ -34,7 +37,6 @@ require ( github.com/RichardKnop/machinery/v2 v2.0.11 // indirect github.com/andybalholm/brotli v1.0.5 // indirect github.com/aws/aws-sdk-go v1.37.16 // indirect - github.com/bytedance/sonic v1.9.2 // indirect github.com/cenkalti/backoff/v4 v4.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect @@ -161,7 +163,6 @@ require ( go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.11.0 // indirect golang.org/x/image v0.0.0-20190802002840-cff245a6509b // indirect golang.org/x/mod v0.11.0 // indirect golang.org/x/net v0.11.0 // indirect diff --git a/go.sum b/go.sum index 1b840525..cabe2cfa 100644 --- a/go.sum +++ b/go.sum @@ -361,6 +361,8 @@ github.com/goravel/framework v1.12.1-0.20230721095426-6f24ecdaf6a7 h1:2rkC/6M7tL github.com/goravel/framework v1.12.1-0.20230721095426-6f24ecdaf6a7/go.mod h1:1lxwtCXMkotSmt0YWRg/s7EnMUFfmne9L1a50X9J0fA= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= diff --git a/pkg/ssh/ssh.go b/pkg/ssh/ssh.go new file mode 100644 index 00000000..1048d919 --- /dev/null +++ b/pkg/ssh/ssh.go @@ -0,0 +1,72 @@ +package ssh + +import ( + "time" + + "golang.org/x/crypto/ssh" + + "panel/pkg/tools" +) + +type AuthMethod int8 + +const ( + PASSWORD AuthMethod = iota + 1 + PUBLICKEY +) + +type SSHClientConfig struct { + AuthMethod AuthMethod + HostAddr string + User string + Password string + KeyPath string + Timeout time.Duration +} + +func SSHClientConfigPassword(hostAddr, user, Password string) *SSHClientConfig { + return &SSHClientConfig{ + Timeout: time.Second * 5, + AuthMethod: PASSWORD, + HostAddr: hostAddr, + User: user, + Password: Password, + } +} + +func SSHClientConfigPulicKey(hostAddr, user, keyPath string) *SSHClientConfig { + return &SSHClientConfig{ + Timeout: time.Second * 5, + AuthMethod: PUBLICKEY, + HostAddr: hostAddr, + User: user, + KeyPath: keyPath, + } +} + +func NewSSHClient(conf *SSHClientConfig) (*ssh.Client, error) { + config := &ssh.ClientConfig{ + Timeout: conf.Timeout, + User: conf.User, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + switch conf.AuthMethod { + case PASSWORD: + config.Auth = []ssh.AuthMethod{ssh.Password(conf.Password)} + case PUBLICKEY: + signer, err := getKey(conf.KeyPath) + if err != nil { + return nil, err + } + config.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)} + } + c, err := ssh.Dial("tcp", conf.HostAddr, config) + if err != nil { + return nil, err + } + return c, nil +} + +func getKey(keyPath string) (ssh.Signer, error) { + return ssh.ParsePrivateKey([]byte(tools.ReadFile(keyPath))) +} diff --git a/pkg/ssh/turn.go b/pkg/ssh/turn.go new file mode 100644 index 00000000..99e35bd7 --- /dev/null +++ b/pkg/ssh/turn.go @@ -0,0 +1,139 @@ +package ssh + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/gorilla/websocket" + "golang.org/x/crypto/ssh" +) + +const ( + MsgData = '1' + MsgResize = '2' +) + +type Turn struct { + StdinPipe io.WriteCloser + Session *ssh.Session + WsConn *websocket.Conn +} + +func NewTurn(wsConn *websocket.Conn, sshClient *ssh.Client) (*Turn, error) { + sess, err := sshClient.NewSession() + if err != nil { + return nil, err + } + + stdinPipe, err := sess.StdinPipe() + if err != nil { + return nil, err + } + + turn := &Turn{StdinPipe: stdinPipe, Session: sess, WsConn: wsConn} + sess.Stdout = turn + sess.Stderr = turn + + modes := ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud + ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud + } + if err := sess.RequestPty("xterm", 150, 30, modes); err != nil { + return nil, err + } + if err := sess.Shell(); err != nil { + return nil, err + } + + return turn, nil +} + +func (t *Turn) Write(p []byte) (n int, err error) { + writer, err := t.WsConn.NextWriter(websocket.BinaryMessage) + if err != nil { + return 0, err + } + defer writer.Close() + + return writer.Write(p) +} +func (t *Turn) Close() error { + if t.Session != nil { + t.Session.Close() + } + + return t.WsConn.Close() +} + +func (t *Turn) Read(p []byte) (n int, err error) { + for { + msgType, reader, err := t.WsConn.NextReader() + if err != nil { + return 0, err + } + if msgType != websocket.BinaryMessage { + continue + } + + return reader.Read(p) + } +} + +func (t *Turn) LoopRead(logBuff *bytes.Buffer, context context.Context) error { + for { + select { + case <-context.Done(): + return errors.New("LoopRead exit") + default: + _, wsData, err := t.WsConn.ReadMessage() + if err != nil { + return fmt.Errorf("reading webSocket message err:%s", err) + } + body := decode(wsData[1:]) + switch wsData[0] { + case MsgResize: + var args Resize + err := json.Unmarshal(body, &args) + if err != nil { + return fmt.Errorf("ssh pty resize windows err:%s", err) + } + if args.Columns > 0 && args.Rows > 0 { + if err := t.Session.WindowChange(args.Rows, args.Columns); err != nil { + return fmt.Errorf("ssh pty resize windows err:%s", err) + } + } + case MsgData: + if _, err := t.StdinPipe.Write(body); err != nil { + return fmt.Errorf("StdinPipe write err:%s", err) + } + if _, err := logBuff.Write(body); err != nil { + return fmt.Errorf("logBuff write err:%s", err) + } + } + } + } +} + +func (t *Turn) SessionWait() error { + if err := t.Session.Wait(); err != nil { + return err + } + + return nil +} + +func decode(p []byte) []byte { + decodeString, _ := base64.StdEncoding.DecodeString(string(p)) + return decodeString +} + +type Resize struct { + Columns int + Rows int +} diff --git a/public/index.html b/public/index.html index 1f7db824..bf0d281d 100644 --- a/public/index.html +++ b/public/index.html @@ -39,6 +39,9 @@ + + + + + diff --git a/public/panel/views/website/add.html b/public/panel/views/website/add.html index bf3f71fe..67b410ff 100644 --- a/public/panel/views/website/add.html +++ b/public/panel/views/website/add.html @@ -114,7 +114,7 @@ Date: 2023-06-24 }) form.on('select(add-website-db)', function (data) { - if (data.value === '') { + if (data.value === "false") { $('#add-website-db-info').hide() return false } @@ -130,7 +130,7 @@ Date: 2023-06-24 }) // 提交 form.on('submit(add-website-submit)', function (data) { - data.field.db = data.field.db_type !== ''; + data.field.db = data.field.db_type !== "false"; admin.req({ url: '/api/panel/website/add' , type: 'post' diff --git a/routes/web.go b/routes/web.go index 9d8c6963..11c76661 100644 --- a/routes/web.go +++ b/routes/web.go @@ -95,6 +95,12 @@ func Web() { r.Get("list", monitorController.List) r.Get("switchAndDays", monitorController.SwitchAndDays) }) + r.Prefix("ssh").Middleware(middleware.Jwt()).Group(func(r route.Route) { + sshController := controllers.NewSshController() + r.Get("info", sshController.GetInfo) + r.Post("info", sshController.UpdateInfo) + r.Get("session", sshController.Session) + }) r.Prefix("setting").Middleware(middleware.Jwt()).Group(func(r route.Route) { settingController := controllers.NewSettingController() r.Get("list", settingController.List)