diff --git a/main.go b/main.go index b824247..0d4cbf5 100644 --- a/main.go +++ b/main.go @@ -24,7 +24,7 @@ import ( "time" ) -const Version = "v0.2.7" +const Version = "v0.3.0" func main() { log.Fatal(Run()) @@ -182,6 +182,7 @@ func Run() error { }) global.Store = global.NewStore() global.Cron = cron.New(cron.WithSeconds()) //精确到秒 + global.Cron.Start() jobs, err := model.FindJobByFunc(model.FuncCheckAssetStatusJob) if err != nil { @@ -193,6 +194,7 @@ func Run() error { Name: "资产状态检测", Func: model.FuncCheckAssetStatusJob, Cron: "0 0 0/1 * * ?", + Mode: model.JobModeAll, Status: model.JobStatusRunning, Created: utils.NowJsonTime(), Updated: utils.NowJsonTime(), @@ -200,6 +202,7 @@ func Run() error { if err := model.CreateNewJob(&job); err != nil { return err } + logrus.Debugf("创建计划任务「%v」cron「%v」", job.Name, job.Cron) } else { for i := range jobs { if jobs[i].Status == model.JobStatusRunning { @@ -207,6 +210,7 @@ func Run() error { if err != nil { return err } + logrus.Debugf("启动计划任务「%v」cron「%v」", jobs[i].Name, jobs[i].Cron) } } } diff --git a/pkg/api/job.go b/pkg/api/job.go index 8d11d50..1fdc31c 100644 --- a/pkg/api/job.go +++ b/pkg/api/job.go @@ -3,6 +3,7 @@ package api import ( "github.com/labstack/echo/v4" "next-terminal/pkg/model" + "next-terminal/pkg/utils" "strconv" "strings" ) @@ -13,6 +14,9 @@ func JobCreateEndpoint(c echo.Context) error { return err } + item.ID = utils.UUID() + item.Created = utils.NowJsonTime() + if err := model.CreateNewJob(&item); err != nil { return err } diff --git a/pkg/api/middleware.go b/pkg/api/middleware.go index 8e925e5..3092b5b 100644 --- a/pkg/api/middleware.go +++ b/pkg/api/middleware.go @@ -27,7 +27,7 @@ func ErrorHandler(next echo.HandlerFunc) echo.HandlerFunc { func Auth(next echo.HandlerFunc) echo.HandlerFunc { - urls := []string{"download", "recording", "login", "static", "favicon", "logo", "asciinema"} + urls := []string{"/download", "/recording", "/login", "/static", "/favicon.ico", "/logo.svg", "/asciinema"} return func(c echo.Context) error { // 路由拦截 - 登录身份、资源权限判断等 @@ -35,7 +35,7 @@ func Auth(next echo.HandlerFunc) echo.HandlerFunc { if c.Request().RequestURI == "/" || strings.HasPrefix(c.Request().RequestURI, "/#") { return next(c) } - if strings.Contains(c.Request().RequestURI, urls[i]) { + if strings.HasPrefix(c.Request().RequestURI, urls[i]) { return next(c) } } diff --git a/pkg/api/session.go b/pkg/api/session.go index fce379d..9bf6ecc 100644 --- a/pkg/api/session.go +++ b/pkg/api/session.go @@ -46,10 +46,8 @@ func SessionPagingEndpoint(c echo.Context) error { } if utils.FileExists(recording) { - logrus.Debugf("检测到录屏文件[%v]存在", recording) items[i].Recording = "1" } else { - logrus.Warnf("检测到录屏文件[%v]不存在", recording) items[i].Recording = "0" } } else { diff --git a/pkg/handle/runner.go b/pkg/handle/runner.go index 4142f22..11f2429 100644 --- a/pkg/handle/runner.go +++ b/pkg/handle/runner.go @@ -2,7 +2,6 @@ package handle import ( "github.com/sirupsen/logrus" - "next-terminal/pkg/global" "next-terminal/pkg/guacd" "next-terminal/pkg/model" "next-terminal/pkg/utils" @@ -14,50 +13,55 @@ import ( func RunTicker() { // 每隔一小时删除一次未使用的会话信息 - _, _ = global.Cron.AddFunc("0 0 0/1 * * ?", func() { - sessions, _ := model.FindSessionByStatusIn([]string{model.NoConnect, model.Connecting}) - if sessions != nil && len(sessions) > 0 { - now := time.Now() - for i := range sessions { - if now.Sub(sessions[i].ConnectedTime.Time) > time.Hour*1 { - _ = model.DeleteSessionById(sessions[i].ID) - s := sessions[i].Username + "@" + sessions[i].IP + ":" + strconv.Itoa(sessions[i].Port) - logrus.Infof("会话「%v」ID「%v」超过1小时未打开,已删除。", s, sessions[i].ID) + unUsedSessionTicker := time.NewTicker(time.Minute * 60) + go func() { + for range unUsedSessionTicker.C { + sessions, _ := model.FindSessionByStatusIn([]string{model.NoConnect, model.Connecting}) + if sessions != nil && len(sessions) > 0 { + now := time.Now() + for i := range sessions { + if now.Sub(sessions[i].ConnectedTime.Time) > time.Hour*1 { + _ = model.DeleteSessionById(sessions[i].ID) + s := sessions[i].Username + "@" + sessions[i].IP + ":" + strconv.Itoa(sessions[i].Port) + logrus.Infof("会话「%v」ID「%v」超过1小时未打开,已删除。", s, sessions[i].ID) + } } } } - }) + }() + // 每日凌晨删除超过时长限制的会话 - _, _ = global.Cron.AddFunc("0 0 0 * * ?", func() { - property, err := model.FindPropertyByName("session-saved-limit") - if err != nil { - return - } - if property.Value == "" || property.Value == "-" { - return - } - limit, err := strconv.Atoi(property.Value) - if err != nil { - return - } - sessions, err := model.FindOutTimeSessions(limit) - if err != nil { - return - } - - if sessions != nil && len(sessions) > 0 { - var sessionIds []string - for i := range sessions { - sessionIds = append(sessionIds, sessions[i].ID) - } - err := model.DeleteSessionByIds(sessionIds) + timeoutSessionTicker := time.NewTicker(time.Hour * 24) + go func() { + for range timeoutSessionTicker.C { + property, err := model.FindPropertyByName("session-saved-limit") if err != nil { - logrus.Errorf("删除离线会话失败 %v", err) + return + } + if property.Value == "" || property.Value == "-" { + return + } + limit, err := strconv.Atoi(property.Value) + if err != nil { + return + } + sessions, err := model.FindOutTimeSessions(limit) + if err != nil { + return + } + + if sessions != nil && len(sessions) > 0 { + var sessionIds []string + for i := range sessions { + sessionIds = append(sessionIds, sessions[i].ID) + } + err := model.DeleteSessionByIds(sessionIds) + if err != nil { + logrus.Errorf("删除离线会话失败 %v", err) + } } } - }) - - global.Cron.Start() + }() } func RunDataFix() { diff --git a/pkg/model/asset.go b/pkg/model/asset.go index 2613fe3..64a4d35 100644 --- a/pkg/model/asset.go +++ b/pkg/model/asset.go @@ -48,6 +48,21 @@ func FindAllAsset() (o []Asset, err error) { return } +func FindAssetByIds(assetIds []string) (o []Asset, err error) { + err = global.DB.Where("id in ?", assetIds).Find(&o).Error + return +} + +func FindAssetByProtocol(protocol string) (o []Asset, err error) { + err = global.DB.Where("protocol = ?", protocol).Find(&o).Error + return +} + +func FindAssetByProtocolAndIds(protocol string, assetIds []string) (o []Asset, err error) { + err = global.DB.Where("protocol = ? and id in ?", protocol, assetIds).Find(&o).Error + return +} + func FindAssetByConditions(protocol string, account User) (o []Asset, err error) { db := global.DB.Table("assets").Select("assets.id,assets.name,assets.ip,assets.port,assets.protocol,assets.active,assets.owner,assets.created, users.nickname as owner_name,COUNT(resource_sharers.user_id) as sharer_count").Joins("left join users on assets.owner = users.id").Joins("left join resource_sharers on assets.id = resource_sharers.resource_id").Group("assets.id") diff --git a/pkg/model/job.go b/pkg/model/job.go index b024086..c8d983d 100644 --- a/pkg/model/job.go +++ b/pkg/model/job.go @@ -1,12 +1,15 @@ package model import ( + "encoding/json" "errors" "fmt" "github.com/robfig/cron/v3" "github.com/sirupsen/logrus" "next-terminal/pkg/global" + "next-terminal/pkg/term" "next-terminal/pkg/utils" + "strings" "time" ) @@ -15,18 +18,24 @@ const ( JobStatusNotRunning = "not-running" FuncCheckAssetStatusJob = "check-asset-status-job" + FuncShellJob = "shell-job" + + JobModeAll = "all" + JobModeCustom = "custom" ) type Job struct { - ID string `gorm:"primary_key" json:"id"` - CronJobId int `json:"cronJobId"` - Name string `json:"name"` - Func string `json:"func"` - Cron string `json:"cron"` - Status string `json:"status"` - Metadata string `json:"metadata"` - Created utils.JsonTime `json:"created"` - Updated utils.JsonTime `json:"updated"` + ID string `gorm:"primary_key" json:"id"` + CronJobId int `json:"cronJobId"` + Name string `json:"name"` + Func string `json:"func"` + Cron string `json:"cron"` + Mode string `json:"mode"` + ResourceIds string `json:"resourceIds"` + Status string `json:"status"` + Metadata string `json:"metadata"` + Created utils.JsonTime `json:"created"` + Updated utils.JsonTime `json:"updated"` } func (r *Job) TableName() string { @@ -69,7 +78,7 @@ func FindJobByFunc(function string) (o []Job, err error) { func CreateNewJob(o *Job) (err error) { if o.Status == JobStatusRunning { - j, err := getJob(o.ID, o.Func) + j, err := getJob(o) if err != nil { return err } @@ -103,7 +112,7 @@ func ChangeJobStatusById(id, status string) (err error) { return err } if status == JobStatusRunning { - j, err := getJob(job.ID, job.Func) + j, err := getJob(&job) if err != nil { return err } @@ -111,10 +120,12 @@ func ChangeJobStatusById(id, status string) (err error) { if err != nil { return err } - job.CronJobId = int(entryID) - return global.DB.Updates(Job{ID: id, Status: JobStatusRunning}).Error + logrus.Debugf("开启计划任务「%v」,运行中计划任务数量「%v」", job.Name, len(global.Cron.Entries())) + + return global.DB.Updates(Job{ID: id, Status: JobStatusRunning, CronJobId: int(entryID)}).Error } else { global.Cron.Remove(cron.EntryID(job.CronJobId)) + logrus.Debugf("关闭计划任务「%v」,运行中计划任务数量「%v」", job.Name, len(global.Cron.Entries())) return global.DB.Updates(Job{ID: id, Status: JobStatusNotRunning}).Error } } @@ -124,7 +135,7 @@ func ExecJobById(id string) (err error) { if err != nil { return err } - j, err := getJob(id, job.Func) + j, err := getJob(&job) if err != nil { return err } @@ -150,10 +161,12 @@ func DeleteJobById(id string) error { return global.DB.Where("id = ?", id).Delete(Job{}).Error } -func getJob(id, function string) (job cron.Job, err error) { - switch function { +func getJob(j *Job) (job cron.Job, err error) { + switch j.Func { case FuncCheckAssetStatusJob: - job = CheckAssetStatusJob{ID: id} + job = CheckAssetStatusJob{ID: j.ID, Mode: j.Mode, ResourceIds: j.ResourceIds, Metadata: j.Metadata} + case FuncShellJob: + job = ShellJob{ID: j.ID, Mode: j.Mode, ResourceIds: j.ResourceIds, Metadata: j.Metadata} default: return nil, errors.New("未识别的任务") } @@ -161,44 +174,176 @@ func getJob(id, function string) (job cron.Job, err error) { } type CheckAssetStatusJob struct { - ID string + ID string + Mode string + ResourceIds string + Metadata string } func (r CheckAssetStatusJob) Run() { - assets, _ := FindAllAsset() - if assets != nil && len(assets) > 0 { - - msgChan := make(chan string) - for i := range assets { - asset := assets[i] - go func() { - t1 := time.Now() - active := utils.Tcping(asset.IP, asset.Port) - elapsed := time.Since(t1) - msg := fmt.Sprintf("资产「%v」存活状态检测完成,存活「%v」,耗时「%v」", asset.Name, active, elapsed) - - UpdateAssetActiveById(active, asset.ID) - logrus.Infof(msg) - msgChan <- msg - }() - } - - if r.ID != "" { - var message = "" - for i := 0; i < len(assets); i++ { - message += <-msgChan + "\n" - } - - _ = UpdateJonUpdatedById(r.ID) - jobLog := JobLog{ - ID: utils.UUID(), - JobId: r.ID, - Timestamp: utils.NowJsonTime(), - Message: message, - } - - _ = CreateNewJobLog(&jobLog) - } + if r.ID == "" { + return } + var assets []Asset + if r.Mode == JobModeAll { + assets, _ = FindAllAsset() + } else { + assets, _ = FindAssetByIds(strings.Split(r.ResourceIds, ",")) + } + + if assets == nil || len(assets) == 0 { + return + } + + msgChan := make(chan string) + for i := range assets { + asset := assets[i] + go func() { + t1 := time.Now() + active := utils.Tcping(asset.IP, asset.Port) + elapsed := time.Since(t1) + msg := fmt.Sprintf("资产「%v」存活状态检测完成,存活「%v」,耗时「%v」", asset.Name, active, elapsed) + + UpdateAssetActiveById(active, asset.ID) + logrus.Infof(msg) + msgChan <- msg + }() + } + + var message = "" + for i := 0; i < len(assets); i++ { + message += <-msgChan + "\n" + } + + _ = UpdateJonUpdatedById(r.ID) + jobLog := JobLog{ + ID: utils.UUID(), + JobId: r.ID, + Timestamp: utils.NowJsonTime(), + Message: message, + } + + _ = CreateNewJobLog(&jobLog) +} + +type ShellJob struct { + ID string + Mode string + ResourceIds string + Metadata string +} + +type MetadataShell struct { + Shell string +} + +func (r ShellJob) Run() { + if r.ID == "" { + return + } + + var assets []Asset + if r.Mode == JobModeAll { + assets, _ = FindAssetByProtocol("ssh") + } else { + assets, _ = FindAssetByProtocolAndIds("ssh", strings.Split(r.ResourceIds, ",")) + } + + if assets == nil || len(assets) == 0 { + return + } + + var metadataShell MetadataShell + err := json.Unmarshal([]byte(r.Metadata), &metadataShell) + if err != nil { + logrus.Errorf("JSON数据解析失败 %v", err) + return + } + + msgChan := make(chan string) + for i := range assets { + asset, err := FindAssetById(assets[i].ID) + if err != nil { + msgChan <- fmt.Sprintf("资产「%v」Shell执行失败,查询数据异常「%v」", assets[i].Name, err.Error()) + return + } + + var ( + username = asset.Username + password = asset.Password + privateKey = asset.PrivateKey + passphrase = asset.Passphrase + ip = asset.IP + port = asset.Port + ) + + if asset.AccountType == "credential" { + credential, err := FindCredentialById(asset.CredentialId) + if err != nil { + msgChan <- fmt.Sprintf("资产「%v」Shell执行失败,查询授权凭证数据异常「%v」", assets[i].Name, err.Error()) + return + } + + if credential.Type == Custom { + username = credential.Username + password = credential.Password + } else { + username = credential.Username + privateKey = credential.PrivateKey + passphrase = credential.Passphrase + } + } + + go func() { + + t1 := time.Now() + result, err := ExecCommandBySSH(metadataShell.Shell, ip, port, username, password, privateKey, passphrase) + elapsed := time.Since(t1) + var msg string + if err != nil { + msg = fmt.Sprintf("资产「%v」Shell执行失败,返回值「%v」,耗时「%v」", asset.Name, err.Error(), elapsed) + logrus.Infof(msg) + } else { + msg = fmt.Sprintf("资产「%v」Shell执行成功,返回值「%v」,耗时「%v」", asset.Name, result, elapsed) + logrus.Infof(msg) + } + + msgChan <- msg + }() + } + + var message = "" + for i := 0; i < len(assets); i++ { + message += <-msgChan + "\n" + } + + _ = UpdateJonUpdatedById(r.ID) + jobLog := JobLog{ + ID: utils.UUID(), + JobId: r.ID, + Timestamp: utils.NowJsonTime(), + Message: message, + } + + _ = CreateNewJobLog(&jobLog) +} + +func ExecCommandBySSH(cmd, ip string, port int, username, password, privateKey, passphrase string) (result string, err error) { + sshClient, err := term.NewSshClient(ip, port, username, password, privateKey, passphrase) + if err != nil { + return "", err + } + + session, err := sshClient.NewSession() + if err != nil { + return "", err + } + defer session.Close() + //执行远程命令 + combo, err := session.CombinedOutput(cmd) + if err != nil { + return "", err + } + return string(combo), nil } diff --git a/web/package.json b/web/package.json index 5dbd71a..9759917 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "next-terminal", - "version": "0.2.7", + "version": "0.3.0", "private": true, "dependencies": { "@ant-design/icons": "^4.3.0", diff --git a/web/src/components/asset/Asset.js b/web/src/components/asset/Asset.js index 27901e2..b1cca2e 100644 --- a/web/src/components/asset/Asset.js +++ b/web/src/components/asset/Asset.js @@ -490,7 +490,7 @@ class Asset extends Component { if (tags[i] === '-') { continue; } - tagDocuments.push({tagArr[i]}) + tagDocuments.push({tagArr[i]}) } return tagDocuments; } diff --git a/web/src/components/job/Job.js b/web/src/components/job/Job.js index 2a36e8b..fe9a9f0 100644 --- a/web/src/components/job/Job.js +++ b/web/src/components/job/Job.js @@ -12,10 +12,10 @@ import { PageHeader, Row, Space, + Spin, Switch, Table, Tag, - Timeline, Tooltip, Typography } from "antd"; @@ -68,6 +68,7 @@ class Job extends Component { modalConfirmLoading: false, selectedRow: undefined, selectedRowKeys: [], + logPending: false, logs: [] }; @@ -159,11 +160,19 @@ class Job extends Component { }); }; - showModal(title, assets = null) { + showModal(title, obj = null) { + if (obj['func'] === 'shell-job') { + obj['shell'] = JSON.parse(obj['metadata'])['shell']; + } + + if (obj['mode'] === 'custom') { + obj['resourceIds'] = obj['resourceIds'].split(','); + } + this.setState({ modalTitle: title, modalVisible: true, - model: assets + model: obj }); }; @@ -180,6 +189,19 @@ class Job extends Component { modalConfirmLoading: true }); + console.log(formData) + if (formData['func'] === 'shell-job') { + console.log(formData['shell'], JSON.stringify({'shell': formData['shell']})) + + formData['metadata'] = JSON.stringify({'shell': formData['shell']}); + formData['shell'] = undefined; + } + + if (formData['mode'] === 'custom') { + let resourceIds = formData['resourceIds']; + formData['resourceIds'] = resourceIds.join(','); + } + if (formData.id) { // 向后台提交数据 const result = await request.put('/jobs/' + formData.id, formData); @@ -284,7 +306,9 @@ class Job extends Component { render: (func, record) => { switch (func) { case "check-asset-status-job": - return 资产状态检测 + return 资产状态检测; + case "shell-job": + return Shell脚本 } } }, { @@ -307,6 +331,9 @@ class Job extends Component { dataIndex: 'updated', key: 'updated', render: (text, record) => { + if (text === '0001-01-01 00:00:00') { + return ''; + } return ( {dayjs(text).fromNow()} @@ -330,7 +357,7 @@ class Job extends Component { onClick={async () => { this.setState({ logVisible: true, - logPending: '正在加载...' + logPending: true }) let result = await request.get(`/jobs/${record['id']}/logs`); @@ -401,7 +428,7 @@ class Job extends Component { <> ]} - subTitle="定时任务" + subTitle="计划任务" > @@ -444,7 +471,7 @@ class Job extends Component { @@ -548,7 +575,7 @@ class Job extends Component { okType={'danger'} cancelText='取消' > - +
                                         {
                                             this.state.logs.map(item => {
@@ -559,8 +586,7 @@ class Job extends Component {
                                             })
                                         }
                                     
- -
+ : undefined } diff --git a/web/src/components/job/JobModal.js b/web/src/components/job/JobModal.js index ae94e37..920f3d3 100644 --- a/web/src/components/job/JobModal.js +++ b/web/src/components/job/JobModal.js @@ -1,9 +1,41 @@ -import React from 'react'; -import {Form, Input, Modal, Radio, Select} from "antd/lib/index"; +import React, {useEffect, useState} from 'react'; +import {Form, Input, Modal, Radio, Select, Spin} from "antd/lib/index"; +import TextArea from "antd/es/input/TextArea"; +import request from "../../common/request"; +import {message} from "antd"; const JobModal = ({title, visible, handleOk, handleCancel, confirmLoading, model}) => { const [form] = Form.useForm(); + if (model.func === undefined) { + model.func = 'shell-job'; + } + + if (model.mode === undefined) { + model.mode = 'all'; + } + + let [func, setFunc] = useState(model.func); + let [mode, setMode] = useState(model.mode); + let [resources, setResources] = useState([]); + useEffect(() => { + const fetchData = async () => { + setResourcesLoading(true); + let result = await request.get('/assets?protocol=ssh'); + if (result['code'] === 1) { + setResources(result['data']); + } else { + message.error(result['message'], 10); + } + setResourcesLoading(false); + }; + + fetchData(); + + }, []); + + + let [resourcesLoading, setResourcesLoading] = useState(false); const formItemLayout = { labelCol: {span: 6}, @@ -39,9 +71,9 @@ const JobModal = ({title, visible, handleOk, handleCancel, confirmLoading, model @@ -50,10 +82,45 @@ const JobModal = ({title, visible, handleOk, handleCancel, confirmLoading, model + { + func === 'shell-job' ? + +