2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 04:22:33 +08:00

feat: ssh多机管理

This commit is contained in:
耗子
2024-10-23 02:39:43 +08:00
parent 346ec7ab1f
commit 3c72a66c1f
26 changed files with 695 additions and 222 deletions

View File

@@ -17,10 +17,6 @@ const (
SettingKeyBackupPath SettingKey = "backup_path"
SettingKeyWebsitePath SettingKey = "website_path"
SettingKeyMySQLRootPassword SettingKey = "mysql_root_password"
SettingKeySshHost SettingKey = "ssh_host"
SettingKeySshPort SettingKey = "ssh_port"
SettingKeySshUser SettingKey = "ssh_user"
SettingKeySshPassword SettingKey = "ssh_password"
SettingKeyOfflineMode SettingKey = "offline_mode"
)

View File

@@ -9,14 +9,19 @@ import (
type SSH struct {
ID uint `gorm:"primaryKey" json:"id"`
Host string `json:"host"`
Port uint `json:"port"`
Config ssh.ClientConfig `json:"config"`
Name string `gorm:"not null" json:"name"`
Host string `gorm:"not null" json:"host"`
Port uint `gorm:"not null" json:"port"`
Config ssh.ClientConfig `gorm:"not null;serializer:json" json:"config"`
Remark string `gorm:"not null" json:"remark"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type SSHRepo interface {
GetInfo() (map[string]any, error)
UpdateInfo(req *request.SSHUpdateInfo) error
List(page, limit uint) ([]*SSH, int64, error)
Get(id uint) (*SSH, error)
Create(req *request.SSHCreate) error
Update(req *request.SSHUpdate) error
Delete(id uint) error
}

View File

@@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"path/filepath"
"regexp"
"strconv"
"time"
@@ -54,10 +53,6 @@ func (r *cronRepo) Get(id uint) (*biz.Cron, error) {
}
func (r *cronRepo) Create(req *request.CronCreate) error {
if !regexp.MustCompile(`^((\*|\d+|\d+-\d+|\d+/\d+|\d+-\d+/\d+|\*/\d+)(,(\*|\d+|\d+-\d+|\d+/\d+|\d+-\d+/\d+|\*/\d+))*\s?){5}$`).MatchString(req.Time) {
return errors.New("时间格式错误")
}
var script string
if req.Type == "backup" {
if req.BackupType == "website" {
@@ -136,10 +131,6 @@ func (r *cronRepo) Update(req *request.CronUpdate) error {
return err
}
if !regexp.MustCompile(`^((\*|\d+|\d+-\d+|\d+/\d+|\d+-\d+/\d+|\*/\d+)(,(\*|\d+|\d+-\d+|\d+/\d+|\d+-\d+/\d+|\*/\d+))*\s?){5}$`).MatchString(req.Time) {
return errors.New("时间格式错误")
}
if !cron.Status {
return errors.New("计划任务已禁用")
}

View File

@@ -1,12 +1,12 @@
package data
import (
"errors"
"github.com/spf13/cast"
"fmt"
"github.com/TheTNB/panel/internal/app"
"github.com/TheTNB/panel/internal/biz"
"github.com/TheTNB/panel/internal/http/request"
pkgssh "github.com/TheTNB/panel/pkg/ssh"
)
type sshRepo struct {
@@ -19,36 +19,71 @@ func NewSSHRepo() biz.SSHRepo {
}
}
func (r *sshRepo) GetInfo() (map[string]any, error) {
host, _ := r.settingRepo.Get(biz.SettingKeySshHost)
port, _ := r.settingRepo.Get(biz.SettingKeySshPort)
user, _ := r.settingRepo.Get(biz.SettingKeySshUser)
password, _ := r.settingRepo.Get(biz.SettingKeySshPassword)
if len(host) == 0 || len(user) == 0 || len(password) == 0 {
return nil, errors.New("SSH 配置不完整")
}
return map[string]any{
"host": host,
"port": cast.ToInt(port),
"user": user,
"password": password,
}, nil
func (r *sshRepo) List(page, limit uint) ([]*biz.SSH, int64, error) {
var ssh []*biz.SSH
var total int64
err := app.Orm.Model(&biz.SSH{}).Omit("Hosts").Order("id desc").Count(&total).Offset(int((page - 1) * limit)).Limit(int(limit)).Find(&ssh).Error
return ssh, total, err
}
func (r *sshRepo) UpdateInfo(req *request.SSHUpdateInfo) error {
if err := r.settingRepo.Set(biz.SettingKeySshHost, req.Host); err != nil {
return err
}
if err := r.settingRepo.Set(biz.SettingKeySshPort, cast.ToString(req.Port)); err != nil {
return err
}
if err := r.settingRepo.Set(biz.SettingKeySshUser, req.User); err != nil {
return err
}
if err := r.settingRepo.Set(biz.SettingKeySshPassword, req.Password); err != nil {
return err
func (r *sshRepo) Get(id uint) (*biz.SSH, error) {
ssh := new(biz.SSH)
if err := app.Orm.Where("id = ?", id).First(ssh).Error; err != nil {
return nil, err
}
return nil
return ssh, nil
}
func (r *sshRepo) Create(req *request.SSHCreate) error {
conf := pkgssh.ClientConfig{
AuthMethod: pkgssh.AuthMethod(req.AuthMethod),
Host: fmt.Sprintf("%s:%d", req.Host, req.Port),
User: req.User,
Password: req.Password,
Key: req.Key,
}
_, err := pkgssh.NewSSHClient(conf)
if err != nil {
return fmt.Errorf("failed to check ssh connection: %v", err)
}
ssh := &biz.SSH{
Name: req.Name,
Host: req.Host,
Port: req.Port,
Config: conf,
Remark: req.Remark,
}
return app.Orm.Create(ssh).Error
}
func (r *sshRepo) Update(req *request.SSHUpdate) error {
conf := pkgssh.ClientConfig{
AuthMethod: pkgssh.AuthMethod(req.AuthMethod),
Host: fmt.Sprintf("%s:%d", req.Host, req.Port),
User: req.User,
Password: req.Password,
Key: req.Key,
}
_, err := pkgssh.NewSSHClient(conf)
if err != nil {
return fmt.Errorf("failed to check ssh connection: %v", err)
}
ssh := &biz.SSH{
ID: req.ID,
Name: req.Name,
Host: req.Host,
Port: req.Port,
Config: conf,
Remark: req.Remark,
}
return app.Orm.Model(ssh).Updates(ssh).Error
}
func (r *sshRepo) Delete(id uint) error {
return app.Orm.Delete(&biz.SSH{}, id).Error
}

View File

@@ -1,8 +1,24 @@
package request
type SSHUpdateInfo struct {
Host string `json:"host" form:"host" validate:"required"`
Port int `json:"port" form:"port" validate:"required,number,gte=1,lte=65535"`
User string `json:"user" form:"user" validate:"required"`
Password string `json:"password" form:"password" validate:"required"`
type SSHCreate struct {
Name string `json:"name" form:"name"`
Host string `json:"host" form:"host" validate:"required"`
Port uint `json:"port" form:"port" validate:"required,number,gte=1,lte=65535"`
AuthMethod string `json:"auth_method" form:"auth_method" validate:"required,oneof=password publickey"`
User string `json:"user" form:"user" validate:"required_if=AuthMethod password"`
Password string `json:"password" form:"password" validate:"required_if=AuthMethod password"`
Key string `json:"key" form:"key" validate:"required_if=AuthMethod publickey"`
Remark string `json:"remark" form:"remark"`
}
type SSHUpdate struct {
ID uint `form:"id" json:"id" validate:"required,exists=sshes id"`
Name string `json:"name" form:"name"`
Host string `json:"host" form:"host" validate:"required"`
Port uint `json:"port" form:"port" validate:"required,number,gte=1,lte=65535"`
AuthMethod string `json:"auth_method" form:"auth_method" validate:"required,oneof=password publickey"`
User string `json:"user" form:"user" validate:"required_if=AuthMethod password"`
Password string `json:"password" form:"password" validate:"required_if=AuthMethod password"`
Key string `json:"key" form:"key" validate:"required_if=AuthMethod publickey"`
Remark string `json:"remark" form:"remark"`
}

View File

@@ -42,4 +42,17 @@ func init() {
)
},
})
Migrations = append(Migrations, &gormigrate.Migration{
ID: "20241022-ssh",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
&biz.SSH{},
)
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable(
&biz.SSH{},
)
},
})
}

View File

@@ -158,8 +158,11 @@ func Http(r chi.Router) {
r.Route("/ssh", func(r chi.Router) {
r.Use(middleware.MustLogin)
ssh := service.NewSSHService()
r.Get("/info", ssh.GetInfo)
r.Post("/info", ssh.UpdateInfo)
r.Get("/", ssh.List)
r.Post("/", ssh.Create)
r.Put("/{id}", ssh.Update)
r.Get("/{id}", ssh.Get)
r.Delete("/{id}", ssh.Delete)
})
r.Route("/container", func(r chi.Router) {

View File

@@ -1,6 +1,7 @@
package service
import (
"github.com/go-rat/chix"
"net/http"
"github.com/TheTNB/panel/internal/biz"
@@ -18,25 +19,82 @@ func NewSSHService() *SSHService {
}
}
func (s *SSHService) GetInfo(w http.ResponseWriter, r *http.Request) {
info, err := s.sshRepo.GetInfo()
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, info)
}
func (s *SSHService) UpdateInfo(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.SSHUpdateInfo](r)
func (s *SSHService) List(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.Paginate](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = s.sshRepo.UpdateInfo(req); err != nil {
cron, total, err := s.sshRepo.List(req.Page, req.Limit)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, chix.M{
"total": total,
"items": cron,
})
}
func (s *SSHService) Create(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.SSHCreate](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = s.sshRepo.Create(req); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
func (s *SSHService) Update(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.SSHUpdate](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = s.sshRepo.Update(req); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
func (s *SSHService) Get(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ID](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
cron, err := s.sshRepo.Get(req.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, cron)
}
func (s *SSHService) Delete(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ID](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = s.sshRepo.Delete(req.ID); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}

View File

@@ -3,17 +3,16 @@ package service
import (
"bufio"
"context"
"github.com/TheTNB/panel/internal/http/request"
"net/http"
"sync"
"github.com/gorilla/websocket"
"github.com/spf13/cast"
"github.com/TheTNB/panel/internal/app"
"github.com/TheTNB/panel/internal/biz"
"github.com/TheTNB/panel/internal/data"
"github.com/TheTNB/panel/pkg/shell"
"github.com/TheTNB/panel/pkg/ssh"
"github.com/gorilla/websocket"
)
type WsService struct {
@@ -27,11 +26,17 @@ func NewWsService() *WsService {
}
func (s *WsService) Session(w http.ResponseWriter, r *http.Request) {
info, err := s.sshRepo.GetInfo()
req, err := Bind[request.ID](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
info, err := s.sshRepo.Get(req.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
ws, err := s.upgrade(w, r)
if err != nil {
ErrorSystem(w)
@@ -39,12 +44,7 @@ func (s *WsService) Session(w http.ResponseWriter, r *http.Request) {
}
defer ws.Close()
config := ssh.ClientConfigPassword(
cast.ToString(info["host"])+":"+cast.ToString(info["port"]),
cast.ToString(info["user"]),
cast.ToString(info["password"]),
)
client, err := ssh.NewSSHClient(config)
client, err := ssh.NewSSHClient(info.Config)
if err != nil {
_ = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, err.Error()))
return

View File

@@ -6,43 +6,47 @@ import (
"golang.org/x/crypto/ssh"
)
type AuthMethod int8
type AuthMethod string
const (
PASSWORD AuthMethod = iota + 1
PUBLICKEY
PASSWORD AuthMethod = "password"
PUBLICKEY AuthMethod = "publickey"
)
type ClientConfig struct {
AuthMethod AuthMethod
HostAddr string
User string
Password string
Key string
Timeout time.Duration
AuthMethod AuthMethod `json:"auth_method"`
Host string `json:"host"`
User string `json:"user"`
Password string `json:"password"`
Key string `json:"key"`
Timeout time.Duration `json:"timeout"`
}
func ClientConfigPassword(hostAddr, user, Password string) *ClientConfig {
func ClientConfigPassword(host, user, Password string) *ClientConfig {
return &ClientConfig{
Timeout: time.Second * 5,
Timeout: 10 * time.Second,
AuthMethod: PASSWORD,
HostAddr: hostAddr,
Host: host,
User: user,
Password: Password,
}
}
func ClientConfigPublicKey(hostAddr, user, key string) *ClientConfig {
func ClientConfigPublicKey(host, user, key string) *ClientConfig {
return &ClientConfig{
Timeout: time.Second * 5,
Timeout: 10 * time.Second,
AuthMethod: PUBLICKEY,
HostAddr: hostAddr,
Host: host,
User: user,
Key: key,
}
}
func NewSSHClient(conf *ClientConfig) (*ssh.Client, error) {
func NewSSHClient(conf ClientConfig) (*ssh.Client, error) {
if conf.Timeout == 0 {
conf.Timeout = 10 * time.Second
}
config := &ssh.ClientConfig{}
config.SetDefaults()
config.Timeout = conf.Timeout
@@ -59,7 +63,7 @@ func NewSSHClient(conf *ClientConfig) (*ssh.Client, error) {
}
config.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
}
c, err := ssh.Dial("tcp", conf.HostAddr, config) // TODO support ipv6
c, err := ssh.Dial("tcp", conf.Host, config) // TODO support ipv6
if err != nil {
return nil, err
}

View File

@@ -20,6 +20,8 @@
"format": "prettier --write src/"
},
"dependencies": {
"@fontsource-variable/noto-sans-sc": "^5.1.0",
"@fontsource/jetbrains-mono": "^5.1.1",
"@guolao/vue-monaco-editor": "^1.5.4",
"@vue-js-cron/naive-ui": "^2.0.5",
"@vueuse/core": "^11.1.0",

30
web/pnpm-lock.yaml generated
View File

@@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@fontsource-variable/noto-sans-sc':
specifier: ^5.1.0
version: 5.1.0
'@fontsource/jetbrains-mono':
specifier: ^5.1.1
version: 5.1.1
'@guolao/vue-monaco-editor':
specifier: ^1.5.4
version: 1.5.4(monaco-editor@0.52.0)(vue@3.5.12(typescript@5.6.3))
@@ -667,6 +673,12 @@ packages:
resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
'@fontsource-variable/noto-sans-sc@5.1.0':
resolution: {integrity: sha512-PnnGFWTVyoSdiZU/QvH3w8NroJkBazLWEzrrfqdGX/S6txq6iDoNMG+bolTsAQ4dXbdvfKBTyX6kw/xg5bmdcg==}
'@fontsource/jetbrains-mono@5.1.1':
resolution: {integrity: sha512-5rwvmdQQpXev4LlBX1P+7h2dguu6iwW/9Npjde4+DEq+HgpVJI/7QY8DI1NVVFdvLtXZNP+vO2L/5BQED6FUhA==}
'@guolao/vue-monaco-editor@1.5.4':
resolution: {integrity: sha512-eyBAqxJeDpV4mZYZSpNvh3xUgKCld5eEe0dBtjJhsy2+L0MB6PYFZ/FbPHNwskgp2RoIpfn1DLrIhXXE3lVbwQ==}
peerDependencies:
@@ -845,30 +857,35 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-glibc@2.4.1':
resolution: {integrity: sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.4.1':
resolution: {integrity: sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.4.1':
resolution: {integrity: sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.4.1':
resolution: {integrity: sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.4.1':
resolution: {integrity: sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg==}
@@ -936,46 +953,55 @@ packages:
resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.24.0':
resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.24.0':
resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.24.0':
resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-powerpc64le-gnu@4.24.0':
resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.24.0':
resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.24.0':
resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.24.0':
resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.24.0':
resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.24.0':
resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==}
@@ -3814,6 +3840,10 @@ snapshots:
'@eslint/js@8.57.1': {}
'@fontsource-variable/noto-sans-sc@5.1.0': {}
'@fontsource/jetbrains-mono@5.1.1': {}
'@guolao/vue-monaco-editor@1.5.4(monaco-editor@0.52.0)(vue@3.5.12(typescript@5.6.3))':
dependencies:
'@monaco-editor/loader': 1.4.0(monaco-editor@0.52.0)

View File

@@ -15,8 +15,6 @@ export default {
request.put('/cron/' + id, { name, time, script }),
// 删除任务
delete: (id: number): Promise<AxiosResponse<any>> => request.delete('/cron/' + id),
// 获取任务日志
log: (id: number): Promise<AxiosResponse<any>> => request.get('/cron/' + id + '/log'),
// 修改任务状态
status: (id: number, status: boolean): Promise<AxiosResponse<any>> =>
request.post('/cron/' + id + '/status', { status })

View File

@@ -3,13 +3,15 @@ import type { AxiosResponse } from 'axios'
import { request } from '@/utils'
export default {
// 获取信息
info: (): Promise<AxiosResponse<any>> => request.get('/ssh/info'),
// 保存信息
saveInfo: (
host: string,
port: number,
user: string,
password: string
): Promise<AxiosResponse<any>> => request.post('/ssh/info', { host, port, user, password })
// 获取主机列表
list: (page: number, limit: number): Promise<AxiosResponse<any>> =>
request.get('/ssh', { params: { page, limit } }),
// 获取主机信息
get: (id: number): Promise<AxiosResponse<any>> => request.get(`/ssh/${id}`),
// 创建主机
create: (req: any): Promise<AxiosResponse<any>> => request.post('/ssh', req),
// 修改主机
update: (id: number, req: any): Promise<AxiosResponse<any>> => request.put(`/ssh/${id}`, req),
// 删除主机
delete: (id: number): Promise<AxiosResponse<any>> => request.delete(`/ssh/${id}`)
}

View File

@@ -1,11 +1,11 @@
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const base = `${protocol}://${window.location.host}/api/ws/`
const base = `${protocol}://${window.location.host}/api/ws`
export default {
// 执行命令
exec: (cmd: string): Promise<WebSocket> => {
return new Promise((resolve, reject) => {
const ws = new WebSocket(base + 'exec')
const ws = new WebSocket(`${base}/exec`)
ws.onopen = () => {
ws.send(cmd)
resolve(ws)
@@ -14,9 +14,9 @@ export default {
})
},
// 连接SSH
ssh: (): Promise<WebSocket> => {
ssh: (id: number): Promise<WebSocket> => {
return new Promise((resolve, reject) => {
const ws = new WebSocket(base + 'ssh')
const ws = new WebSocket(`${base}/ssh?id=${id}`)
ws.onopen = () => resolve(ws)
ws.onerror = (e) => reject(e)
})

View File

@@ -14,7 +14,7 @@ import { setupNaiveDiscreteApi } from './utils'
import { install as VueMonacoEditorPlugin } from '@guolao/vue-monaco-editor'
import dashboard from '@/api/panel/dashboard'
import CronNaivePlugin, { CronNaive } from '@vue-js-cron/naive-ui'
import CronNaivePlugin from '@vue-js-cron/naive-ui'
async function setupApp() {
const app = createApp(App)
@@ -27,7 +27,6 @@ async function setupApp() {
}
})
app.use(CronNaivePlugin)
app.component('CronNaive', CronNaive)
await setupStore(app)
await setupNaiveDiscreteApi()
await setupPanel().then(() => {

View File

@@ -1,3 +1,5 @@
@use '@fontsource-variable/noto-sans-sc';
html {
font-size: 4px; // * 方便unocss计算1单位 = 0.25rem = 1px
}
@@ -8,7 +10,7 @@ body {
height: 100%;
overflow: hidden;
background-color: #f2f2f2;
font-family: 'Encode Sans Condensed', sans-serif;
font-family: 'Noto Sans SC Variable', sans-serif;
}
#app {
@@ -37,7 +39,7 @@ body {
overflow: auto;
&::-webkit-scrollbar {
width: 8;
width: 8px;
height: 8px;
}
}

View File

@@ -244,7 +244,7 @@ onMounted(() => {
<common-page show-footer>
<template #action>
<div flex items-center>
<n-button class="ml-16" type="primary" @click="handleUpdateCache">
<n-button type="primary" @click="handleUpdateCache">
<TheIcon :size="18" icon="material-symbols:refresh" />
{{ $t('appIndex.buttons.updateCache') }}
</n-button>

View File

@@ -281,8 +281,8 @@ const handleTest = async () => {
:disabled="inTest"
:loading="inTest"
@click="handleTest"
w-200
mt-40
w-200
>
{{ inTest ? '跑分中...' : '开始跑分' }}
</n-button>

View File

@@ -76,7 +76,7 @@ onMounted(() => {
</n-alert>
</n-card>
<n-card title="修改端口" rounded-10>
<n-input-number v-model:value="newPort" min="1" />
<n-input-number v-model:value="newPort" :min="1" :max="65535" />
修改 phpMyAdmin 访问端口
</n-card>
</n-space>

View File

@@ -279,7 +279,7 @@ onMounted(() => {
</n-space>
</n-card>
<n-card title="端口设置" rounded-10>
<n-input-number v-model:value="port" min="1" />
<n-input-number v-model:value="port" :min="1" :max="65535" />
修改 Pure-Ftpd 监听端口
</n-card>
</n-space>

View File

@@ -106,7 +106,7 @@ const statusText = (percentage: number) => {
return '运行流畅'
}
const chartDisk = computed(() => {
const chartOptions = computed(() => {
return {
title: {
text: chartType.value == 'net' ? '网络' : '硬盘',
@@ -804,7 +804,7 @@ if (import.meta.hot) {
<n-tag>读写延迟 {{ current.diskRWTime }}ms</n-tag>
</n-flex>
<n-card :bordered="false" h-497>
<v-chart class="chart" :option="chartDisk" autoresize />
<v-chart class="chart" :option="chartOptions" autoresize />
</n-card>
</n-flex>
<n-skeleton v-else text :repeat="24" />

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import ssh from '@/api/panel/ssh'
import { NInput } from 'naive-ui'
const show = defineModel<boolean>('show', { type: Boolean, required: true })
const loading = ref(false)
const model = ref({
name: '',
host: '127.0.0.1',
port: 22,
auth_method: 'password',
user: 'root',
password: '',
key: '',
remark: ''
})
const handleSubmit = async () => {
loading.value = true
await ssh
.create(model.value)
.then(() => {
window.$message.success('创建成功')
loading.value = false
show.value = false
})
.catch(() => {
loading.value = false
})
}
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
title="创建主机"
style="width: 60vw"
size="huge"
:bordered="false"
:segmented="false"
>
<n-form>
<n-form-item label="名称">
<n-input v-model:value="model.name" placeholder="127.0.0.1" />
</n-form-item>
<n-row :gutter="[0, 24]" pt-20>
<n-col :span="15">
<n-form-item label="主机">
<n-input v-model:value="model.host" placeholder="127.0.0.1" />
</n-form-item>
</n-col>
<n-col :span="2"> </n-col>
<n-col :span="7">
<n-form-item label="端口">
<n-input-number v-model:value="model.port" :min="1" :max="65535" />
</n-form-item>
</n-col>
</n-row>
<n-form-item label="认证方式">
<n-select
v-model:value="model.auth_method"
:options="[
{ label: '密码', value: 'password' },
{ label: '私钥', value: 'publickey' }
]"
>
</n-select>
</n-form-item>
<n-form-item v-if="model.auth_method == 'password'" label="用户名">
<n-input v-model:value="model.user" placeholder="root" />
</n-form-item>
<n-form-item v-if="model.auth_method == 'password'" label="密码">
<n-input v-model:value="model.password" />
</n-form-item>
<n-form-item v-if="model.auth_method == 'publickey'" label="私钥">
<n-input v-model:value="model.key" type="textarea" />
</n-form-item>
<n-form-item label="备注">
<n-input v-model:value="model.remark" type="textarea" />
</n-form-item>
</n-form>
<n-row :gutter="[0, 24]" pt-20>
<n-col :span="24">
<n-button type="info" block :loading="loading" @click="handleSubmit"> 提交 </n-button>
</n-col>
</n-row>
</n-modal>
</template>
<style scoped lang="scss"></style>

View File

@@ -3,7 +3,13 @@ defineOptions({
name: 'ssh-index'
})
import ssh from '@/api/panel/ssh'
import ws from '@/api/ws'
import TheIcon from '@/components/custom/TheIcon.vue'
import CreateModal from '@/views/ssh/CreateModal.vue'
import UpdateModal from '@/views/ssh/UpdateModal.vue'
import '@fontsource/jetbrains-mono/400-italic.css'
import '@fontsource/jetbrains-mono/400.css'
import { AttachAddon } from '@xterm/addon-attach'
import { ClipboardAddon } from '@xterm/addon-clipboard'
import { FitAddon } from '@xterm/addon-fit'
@@ -11,18 +17,8 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import { useI18n } from 'vue-i18n'
import ssh from '@/api/panel/ssh'
const { t } = useI18n()
const model = ref({
host: '',
port: 22,
user: '',
password: ''
})
import type { MenuOption } from 'naive-ui'
import { NButton, NFlex, NPopconfirm } from 'naive-ui'
const terminal = ref<HTMLElement | null>(null)
const term = ref()
@@ -30,30 +26,112 @@ let sshWs: WebSocket | null = null
const fitAddon = new FitAddon()
const webglAddon = new WebglAddon()
const handleSave = () => {
ssh
.saveInfo(model.value.host, model.value.port, model.value.user, model.value.password)
.then(() => {
window.$message.success(t('sshIndex.alerts.save'))
const current = ref(0)
const collapsed = ref(true)
const createModal = ref(false)
const updateModal = ref(false)
const updateId = ref(0)
const list = ref<MenuOption[]>([])
const fetchData = async () => {
list.value = []
const { data } = await ssh.list(1, 10000)
if (data.items.length === 0) {
window.$message.info('请先创建主机')
return
}
data.items.forEach((item: any) => {
list.value.push({
label: item.name === '' ? item.host : item.name,
key: item.id,
extra: () => {
return h(
NFlex,
{
size: 'small',
style: 'float: right'
},
{
default: () => [
h(
NButton,
{
type: 'primary',
size: 'small',
onClick: () => {
updateModal.value = true
updateId.value = item.id
}
},
{
default: () => {
return '编辑'
}
}
),
h(
NPopconfirm,
{
onPositiveClick: () => handleDelete(item.id)
},
{
default: () => {
return '确定删除主机吗?'
},
trigger: () => {
return h(
NButton,
{
size: 'small',
type: 'error'
},
{
default: () => {
return '删除'
}
}
)
}
}
)
]
}
)
}
})
}
const getInfo = () => {
ssh.info().then((res) => {
model.value.host = res.data.host
model.value.port = res.data.port
model.value.user = res.data.user
model.value.password = res.data.password
})
await openSession(updateId.value === 0 ? Number(list.value[0].key) : updateId.value)
}
const openSession = () => {
ws.ssh().then((ws) => {
const handleDelete = async (id: number) => {
await ssh.delete(id)
list.value = list.value.filter((item: any) => item.key !== id)
if (current.value === id) {
if (list.value.length > 0) {
await openSession(Number(list.value[0].key))
} else {
term.value.dispose()
}
if (list.value.length === 0) {
createModal.value = true
}
}
}
const handleChange = (key: number) => {
console.log(key)
openSession(key)
}
const openSession = async (id: number) => {
closeSession()
await ws.ssh(id).then((ws) => {
sshWs = ws
term.value = new Terminal({
lineHeight: 1.2,
fontSize: 14,
fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace",
fontFamily: 'JetBrains Mono',
cursorBlink: true,
cursorStyle: 'underline',
tabStopWidth: 4,
@@ -68,24 +146,17 @@ const openSession = () => {
webglAddon.onContextLoss(() => {
webglAddon.dispose()
})
term.value.open(terminal.value!)
fitAddon.fit()
term.value.focus()
window.addEventListener(
'resize',
() => {
fitAddon.fit()
},
false
)
window.addEventListener('resize', onResize, false)
current.value = id
ws.onclose = () => {
term.value.write('\r\n连接已关闭请刷新重试。')
term.value.write('\r\nConnection closed. Please refresh.')
window.removeEventListener('resize', () => {
fitAddon.fit()
})
window.removeEventListener('resize', onResize)
}
ws.onerror = (event) => {
@@ -94,21 +165,33 @@ const openSession = () => {
console.error(event)
ws.close()
}
term.value.onResize(({ cols, rows }: { cols: number; rows: number }) => {
if (ws.readyState === 1) {
ws.send(
JSON.stringify({
resize: true,
columns: cols,
rows: rows
})
)
}
})
})
}
const closeSession = () => {
try {
term.value.dispose()
sshWs?.close()
} catch {
/* empty */
}
terminal.value!.innerHTML = ''
}
const onResize = () => {
fitAddon.fit()
if (sshWs != null && sshWs.readyState === 1) {
const { cols, rows } = term.value
sshWs.send(
JSON.stringify({
resize: true,
columns: cols,
rows: rows
})
)
}
}
const onTermWheel = (event: WheelEvent) => {
if (event.ctrlKey) {
event.preventDefault()
@@ -123,63 +206,99 @@ const onTermWheel = (event: WheelEvent) => {
}
}
watch(createModal, () => {
if (!createModal.value) fetchData()
})
watch(updateModal, () => {
if (!updateModal.value) {
fetchData()
updateId.value = 0
}
})
onMounted(() => {
getInfo()
openSession()
// https://github.com/xtermjs/xterm.js/pull/5178
document.fonts.ready.then((fontFaceSet: any) =>
Promise.all(Array.from(fontFaceSet).map((el: any) => el.load())).then(fetchData)
)
})
onUnmounted(() => {
if (sshWs) {
sshWs.close()
}
closeSession()
})
</script>
<template>
<common-page show-footer>
<n-space vertical>
<n-form inline>
<n-form-item :label="$t('sshIndex.save.fields.host.label')">
<n-input
v-model:value="model.host"
:placeholder="$t('sshIndex.save.fields.host.placeholder')"
clearable
size="small"
/>
</n-form-item>
<n-form-item :label="$t('sshIndex.save.fields.port.label')">
<n-input-number
v-model:value="model.port"
:placeholder="$t('sshIndex.save.fields.port.placeholder')"
clearable
size="small"
/>
</n-form-item>
<n-form-item :label="$t('sshIndex.save.fields.username.label')">
<n-input
v-model:value="model.user"
:placeholder="$t('sshIndex.save.fields.username.placeholder')"
clearable
size="small"
/>
</n-form-item>
<n-form-item :label="$t('sshIndex.save.fields.password.label')">
<n-input
v-model:value="model.password"
:placeholder="$t('sshIndex.save.fields.password.placeholder')"
clearable
size="small"
/>
</n-form-item>
<n-form-item>
<n-button type="primary" size="small" @click="handleSave">
{{ $t('sshIndex.save.actions.submit') }}
</n-button>
</n-form-item>
</n-form>
<n-card>
<div ref="terminal" @wheel="onTermWheel" h-600></div>
</n-card>
</n-space>
<template #action>
<div flex items-center>
<n-button type="primary" @click="createModal = true">
<TheIcon :size="18" icon="material-symbols:add" />
创建主机
</n-button>
</div>
</template>
<n-layout has-sider sider-placement="right">
<n-layout content-style="overflow: visible">
<div ref="terminal" @wheel="onTermWheel" h-75vh></div>
</n-layout>
<n-layout-sider
bordered
:collapsed-width="0"
:collapsed="collapsed"
show-trigger
:native-scrollbar="false"
@collapse="collapsed = true"
@expand="collapsed = false"
@after-enter="onResize"
@after-leave="onResize"
>
<n-menu
v-model:value="current"
:collapsed="collapsed"
:collapsed-width="0"
:collapsed-icon-size="0"
:options="list"
@update-value="handleChange"
/>
</n-layout-sider>
</n-layout>
</common-page>
<CreateModal v-model:show="createModal" />
<UpdateModal v-if="updateModal" v-model:show="updateModal" v-model:id="updateId" />
</template>
<style scoped lang="scss">
:deep(.xterm) {
padding: 4rem !important;
}
:deep(.xterm .xterm-viewport::-webkit-scrollbar) {
border-radius: 0.4rem;
height: 6px;
width: 8px;
}
:deep(.xterm .xterm-viewport::-webkit-scrollbar-thumb) {
background-color: #666;
border-radius: 0.4rem;
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
transition: all 1s;
}
:deep(.xterm .xterm-viewport:hover::-webkit-scrollbar-thumb) {
background-color: #aaa;
}
:deep(.xterm .xterm-viewport::-webkit-scrollbar-track) {
background-color: #111;
border-radius: 0.4rem;
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
transition: all 1s;
}
:deep(.xterm .xterm-viewport:hover::-webkit-scrollbar-track) {
background-color: #444;
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import ssh from '@/api/panel/ssh'
import { NInput } from 'naive-ui'
const show = defineModel<boolean>('show', { type: Boolean, required: true })
const id = defineModel<number>('id', { type: Number, required: true })
const loading = ref(false)
const model = ref({
name: '',
host: '127.0.0.1',
port: 22,
auth_method: 'password',
user: 'root',
password: '',
key: '',
remark: ''
})
const handleSubmit = async () => {
loading.value = true
await ssh
.update(id.value, model.value)
.then(() => {
window.$message.success('更新成功')
loading.value = false
show.value = false
})
.catch(() => {
loading.value = false
})
}
onMounted(() => {
if (id.value > 0) {
ssh.get(id.value).then((res) => {
model.value.name = res.data.name
model.value.host = res.data.host
model.value.port = res.data.port
model.value.auth_method = res.data.config.auth_method
model.value.user = res.data.config.user
model.value.password = res.data.config.password
model.value.key = res.data.config.key
model.value.remark = res.data.remark
})
}
})
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
title="创建主机"
style="width: 60vw"
size="huge"
:bordered="false"
:segmented="false"
>
<n-form>
<n-form-item label="名称">
<n-input v-model:value="model.name" placeholder="127.0.0.1" />
</n-form-item>
<n-row :gutter="[0, 24]" pt-20>
<n-col :span="15">
<n-form-item label="主机">
<n-input v-model:value="model.host" placeholder="127.0.0.1" />
</n-form-item>
</n-col>
<n-col :span="2"> </n-col>
<n-col :span="7">
<n-form-item label="端口">
<n-input-number v-model:value="model.port" :min="1" :max="65535" />
</n-form-item>
</n-col>
</n-row>
<n-form-item label="认证方式">
<n-select
v-model:value="model.auth_method"
:options="[
{ label: '密码', value: 'password' },
{ label: '私钥', value: 'publickey' }
]"
>
</n-select>
</n-form-item>
<n-form-item v-if="model.auth_method == 'password'" label="用户名">
<n-input v-model:value="model.user" placeholder="root" />
</n-form-item>
<n-form-item v-if="model.auth_method == 'password'" label="密码">
<n-input v-model:value="model.password" />
</n-form-item>
<n-form-item v-if="model.auth_method == 'publickey'" label="私钥">
<n-input v-model:value="model.key" type="textarea" />
</n-form-item>
<n-form-item label="备注">
<n-input v-model:value="model.remark" type="textarea" />
</n-form-item>
</n-form>
<n-row :gutter="[0, 24]" pt-20>
<n-col :span="24">
<n-button type="info" block :loading="loading" @click="handleSubmit"> 提交 </n-button>
</n-col>
</n-row>
</n-modal>
</template>
<style scoped lang="scss"></style>

View File

@@ -174,7 +174,7 @@ onMounted(async () => {
:on-create="onCreateListen"
>
<template #default="{ value }">
<div flex items-center w-full>
<div w-full flex items-center >
<n-input v-model:value="value.address" clearable />
<n-checkbox v-model:checked="value.https" ml-20 mr-20 w-120> HTTPS </n-checkbox>
<n-checkbox v-model:checked="value.quic" w-200> QUIC(HTTP3) </n-checkbox>