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)