2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-05 14:53:19 +08:00

feat: web ssh

This commit is contained in:
耗子
2023-07-25 20:33:25 +08:00
parent cf58857fd1
commit cc70ab1fde
13 changed files with 557 additions and 9 deletions

View File

@@ -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()
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 {

5
go.mod
View File

@@ -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

2
go.sum
View File

@@ -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=

72
pkg/ssh/ssh.go Normal file
View File

@@ -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)))
}

139
pkg/ssh/turn.go Normal file
View File

@@ -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
}

View File

@@ -39,6 +39,9 @@
<script src="https://cdnjs.cdn.haozi.net/layui/2.8.11/layui.min.js"></script>
<script src="https://cdnjs.cdn.haozi.net/ace/1.6.1/ace.js"></script>
<script src="https://cdnjs.cdn.haozi.net/echarts/5.4.2/echarts.min.js"></script>
<script src="https://registry.npmmirror.com/xterm/5.2.1/files/lib/xterm.js"></script>
<script src="https://registry.npmmirror.com/xterm-addon-fit/0.7.0/files/lib/xterm-addon-fit.js"></script>
<script src="https://cdnjs.cdn.haozi.net/crypto-js/4.1.1/crypto-js.min.js"></script>
<script>
layui.config({
base: 'panel/', // 静态资源所在路径

View File

@@ -135,9 +135,6 @@ Date: 2023-07-21
$('#setting-api-token input').attr('readonly', true);
}
}
, error: function (xhr, status, error) {
console.log('耗子Linux面板ajax请求出错错误' + error);
}
});
// 面板设置

158
public/panel/views/ssh.html Normal file
View File

@@ -0,0 +1,158 @@
<!--
Name: SSH
Author: 耗子
Date: 2023-07-25
-->
<title>SSH</title>
<link href="https://registry.npmmirror.com/xterm/5.2.1/files/css/xterm.css" rel="stylesheet">
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-header">
SSH
</div>
<div class="layui-card-body">
<div class="layui-form" style="overflow: hidden;" lay-filter="ssh_setting">
<div class="layui-inline">
<span style="margin-right: 10px;">地址</span>
<div class="layui-input-inline">
<input type="text" name="host" class="layui-input"
style="height: 30px; margin-top: 5px;">
</div>
<span style="margin-left: 40px; margin-right: 10px;">端口</span>
<div class="layui-input-inline">
<input type="text" name="port" class="layui-input"
style="height: 30px; margin-top: 5px;">
</div>
<span style="margin-left: 40px; margin-right: 10px;">账号</span>
<div class="layui-input-inline">
<input type="text" name="user" class="layui-input"
style="height: 30px; margin-top: 5px;">
</div>
<span style="margin-left: 40px; margin-right: 10px;">密码</span>
<div class="layui-input-inline">
<input type="text" name="password" class="layui-input"
style="height: 30px; margin-top: 5px;">
</div>
<div class="layui-input-inline">
<button lay-filter="save_ssh_setting" lay-submit class="layui-btn layui-btn-sm"
style="margin-left: 10px;">
保存
</button>
</div>
</div>
</div>
<div id="terminal" style="width: 100%; height: 70vh; background-color: #000000; margin-top: 20px;">
</div>
</div>
</div>
</div>
<script>
layui.use(['admin', 'jquery', 'form'], function () {
var admin = layui.admin;
var $ = layui.jquery;
var form = layui.form;
form.render();
admin.req({
url: "/api/panel/ssh/info"
, type: 'get'
, success: function (result) {
if (result.code !== 0) {
layer.msg('SSH信息获取失败请刷新重试')
return false;
}
form.val("ssh_setting",
result.data
);
}
});
form.on('submit(save_ssh_setting)', function (data) {
admin.req({
url: "/api/panel/ssh/info"
, type: 'post'
, data: data.field
, success: function (result) {
if (result.code !== 0) {
layer.alert('SSH信息保存失败请刷新重试')
return false;
}
layer.alert('SSH信息保存成功')
}
});
return false;
});
})
</script>
<script>
const msgData = '1'
const msgResize = '2'
var terminal = new Terminal({
rendererType: "canvas",
fontSize: 15,
screenKeys: true,
useStyle: true,
cursorBlink: true, // 光标闪烁
theme: {
foreground: "#ECECEC", // 字体
background: "#000000", //背景色
cursor: "help", // 设置光标
lineHeight: 20
}
});
let fitAddon = new FitAddon.FitAddon();
terminal.loadAddon(fitAddon);
fitAddon.fit()
let terminalContainer = document.getElementById("terminal")
let token = layui.data('HaoZiPanel')['access_token']
const webSocket = new WebSocket(`ws://${window.location.host}/api/panel/ssh/session`, [token]);
webSocket.binaryType = 'arraybuffer';
const enc = new TextDecoder("utf-8");
webSocket.onmessage = (event) => {
terminal.write(enc.decode(event.data));
}
webSocket.onopen = () => {
terminal.open(terminalContainer)
fitAddon.fit()
terminal.write("\r\nWelcome to HaoZiPanel SSH. Connection success.\r\n")
terminal.focus()
}
webSocket.onclose = () => {
terminal.write("\r\nSSH connection closed. Please refresh the page.\r\n")
}
webSocket.onerror = (event) => {
console.error(event)
webSocket.close()
}
terminal.onData((data) => {
webSocket.send(msgData + CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(data)), ArrayBuffer)
})
terminal.onResize(({cols, rows}) => {
if (webSocket.readyState === 1) {
webSocket.send(msgResize +
CryptoJS.enc.Base64.stringify(
CryptoJS.enc.Utf8.parse(
JSON.stringify({
columns: cols,
rows: rows
})
)
), ArrayBuffer
)
}
})
window.addEventListener("resize", () => {
fitAddon.fit()
}, false)
</script>

View File

@@ -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'

View File

@@ -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)