From 1f7c4910480a989eb99606b94f4bf4a41042dcfd Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 19 Apr 2026 06:57:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现文件系统日志(FilesystemLog)记录文件管理器操作 - 实现操作日志(OperationLog)记录用户操作行为 - 实现数据库SQL日志(DatabaseSQLLog)模型和API - 实现SSH会话命令记录(SessionCommand)含命令输出和风险等级 - 添加IP提取服务支持X-Real-IP和X-Forwarded-For - 添加日志自动清理功能 - 修复ProFormSwitch required验证问题 - 修复设置页面默认值问题 - 修复文件上传错误检测逻辑 - 修复资产树key前缀问题 - 添加VNC/RDP设置默认值 - 修复文件管理标题翻译 --- server/api/account.go | 13 +- server/api/asset.go | 23 ++++ server/api/asset_group.go | 4 +- server/api/filesystem_api.go | 45 ++++++ server/api/portal_api.go | 47 +++---- server/api/session.go | 3 +- server/api/stub_apis.go | 160 ++++++++++++++++++++-- server/api/stub_apis2.go | 50 ++++++- server/api/term.go | 4 +- server/api/term_handler.go | 120 +++++++++++++++- server/api/user.go | 8 ++ server/api/website_api.go | 2 +- server/app/middleware/tcpwall.go | 3 +- server/env/db.go | 2 +- server/model/access_log.go | 25 ++++ server/model/database_sql_log.go | 24 ++++ server/model/filesystem_log.go | 19 +++ server/model/operation_log.go | 24 ++++ server/model/session_command.go | 18 +++ server/repository/access_log.go | 59 ++++++++ server/repository/database_sql_log.go | 79 +++++++++++ server/repository/filesystem_log.go | 59 ++++++++ server/repository/operation_log.go | 74 ++++++++++ server/repository/session_command.go | 48 +++++++ server/service/database_sql_log.go | 47 +++++++ server/service/operation_log.go | 47 +++++++ server/service/property.go | 88 ++++++++++++ server/task/ticker.go | 116 ++++++++++++++++ web/src/pages/access/AccessPage.tsx | 9 +- web/src/pages/access/AccessSshChooser.tsx | 8 +- web/src/pages/access/FileSystemPage.tsx | 9 +- web/src/pages/assets/AssetPage.tsx | 14 +- web/src/pages/assets/AssetTree.tsx | 18 ++- web/src/pages/sysconf/DbProxySetting.tsx | 1 - web/src/pages/sysconf/LogSetting.tsx | 11 +- web/src/pages/sysconf/MailSetting.tsx | 26 ++-- web/src/pages/sysconf/RdpSetting.tsx | 8 -- web/src/pages/sysconf/SecuritySetting.tsx | 6 - web/src/pages/sysconf/SshdSetting.tsx | 1 - web/src/pages/sysconf/VncSetting.tsx | 20 +-- web/src/react-i18next/locales/en-US.json | 1 + web/src/react-i18next/locales/zh-CN.json | 1 + 42 files changed, 1214 insertions(+), 130 deletions(-) create mode 100644 server/model/access_log.go create mode 100644 server/model/database_sql_log.go create mode 100644 server/model/filesystem_log.go create mode 100644 server/model/operation_log.go create mode 100644 server/model/session_command.go create mode 100644 server/repository/access_log.go create mode 100644 server/repository/database_sql_log.go create mode 100644 server/repository/filesystem_log.go create mode 100644 server/repository/operation_log.go create mode 100644 server/repository/session_command.go create mode 100644 server/service/database_sql_log.go create mode 100644 server/service/operation_log.go diff --git a/server/api/account.go b/server/api/account.go index cbd77d785..41aadc2cc 100644 --- a/server/api/account.go +++ b/server/api/account.go @@ -30,7 +30,8 @@ func (api AccountApi) LoginEndpoint(c echo.Context) error { } // 存储登录失败次数信息 - loginFailCountKey := c.RealIP() + loginAccount.Username + clientIP := service.PropertyService.GetClientIP(c) + loginFailCountKey := clientIP + loginAccount.Username v, ok := cache.LoginFailedKeyManager.Get(loginFailCountKey) if !ok { v = 1 @@ -49,7 +50,7 @@ func (api AccountApi) LoginEndpoint(c echo.Context) error { count++ cache.LoginFailedKeyManager.Set(loginFailCountKey, count, cache.LoginLockExpiration) // 保存登录日志 - if err := service.UserService.SaveLoginLog(c.RealIP(), c.Request().UserAgent(), loginAccount.Username, false, loginAccount.Remember, "", "账号或密码不正确"); err != nil { + if err := service.UserService.SaveLoginLog(clientIP, c.Request().UserAgent(), loginAccount.Username, false, loginAccount.Remember, "", "账号或密码不正确"); err != nil { return err } return FailWithData(c, -1, "您输入的账号或密码不正确", count) @@ -63,7 +64,7 @@ func (api AccountApi) LoginEndpoint(c echo.Context) error { count++ cache.LoginFailedKeyManager.Set(loginFailCountKey, count, cache.LoginLockExpiration) // 保存登录日志 - if err := service.UserService.SaveLoginLog(c.RealIP(), c.Request().UserAgent(), loginAccount.Username, false, loginAccount.Remember, "", "账号或密码不正确"); err != nil { + if err := service.UserService.SaveLoginLog(clientIP, c.Request().UserAgent(), loginAccount.Username, false, loginAccount.Remember, "", "账号或密码不正确"); err != nil { return err } return FailWithData(c, -1, "您输入的账号或密码不正确", count) @@ -78,7 +79,7 @@ func (api AccountApi) LoginEndpoint(c echo.Context) error { count++ cache.LoginFailedKeyManager.Set(loginFailCountKey, count, cache.LoginLockExpiration) // 保存登录日志 - if err := service.UserService.SaveLoginLog(c.RealIP(), c.Request().UserAgent(), loginAccount.Username, false, loginAccount.Remember, "", "双因素认证授权码不正确"); err != nil { + if err := service.UserService.SaveLoginLog(clientIP, c.Request().UserAgent(), loginAccount.Username, false, loginAccount.Remember, "", "双因素认证授权码不正确"); err != nil { return err } return FailWithData(c, -1, "您输入双因素认证授权码不正确", count) @@ -86,12 +87,12 @@ func (api AccountApi) LoginEndpoint(c echo.Context) error { } } - token, err := api.LoginSuccess(loginAccount, user, c.RealIP()) + token, err := api.LoginSuccess(loginAccount, user, clientIP) if err != nil { return err } // 保存登录日志 - if err := service.UserService.SaveLoginLog(c.RealIP(), c.Request().UserAgent(), loginAccount.Username, true, loginAccount.Remember, token, ""); err != nil { + if err := service.UserService.SaveLoginLog(clientIP, c.Request().UserAgent(), loginAccount.Username, true, loginAccount.Remember, token, ""); err != nil { return err } diff --git a/server/api/asset.go b/server/api/asset.go index a377f4c00..29e3eb32f 100644 --- a/server/api/asset.go +++ b/server/api/asset.go @@ -21,6 +21,21 @@ import ( type AssetApi struct{} +func recordOperationLog(c echo.Context, action, content, status, errorMessage string) { + account, _ := GetCurrentAccount(c) + clientIP := service.PropertyService.GetClientIP(c) + _ = service.OperationLogService.Record(context.TODO(), service.OperationLogParams{ + AccountId: account.ID, + AccountName: account.Username, + Action: action, + Content: content, + IP: clientIP, + Status: status, + ErrorMessage: errorMessage, + UserAgent: c.Request().UserAgent(), + }) +} + func (assetApi AssetApi) AssetCreateEndpoint(c echo.Context) error { m := maps.Map{} if err := c.Bind(&m); err != nil { @@ -30,10 +45,13 @@ func (assetApi AssetApi) AssetCreateEndpoint(c echo.Context) error { account, _ := GetCurrentAccount(c) m["owner"] = account.ID + assetName, _ := m["name"].(string) if _, err := service.AssetService.Create(context.TODO(), m); err != nil { + recordOperationLog(c, "asset-add", "创建资产: "+assetName, "failed", err.Error()) return err } + recordOperationLog(c, "asset-add", "创建资产: "+assetName, "success", "") return Success(c, nil) } @@ -167,9 +185,12 @@ func (assetApi AssetApi) AssetUpdateEndpoint(c echo.Context) error { if err := c.Bind(&m); err != nil { return err } + assetName, _ := m["name"].(string) if err := service.AssetService.UpdateById(id, m); err != nil { + recordOperationLog(c, "asset-edit", "更新资产: "+assetName, "failed", err.Error()) return err } + recordOperationLog(c, "asset-edit", "更新资产: "+assetName, "success", "") return Success(c, nil) } @@ -178,10 +199,12 @@ func (assetApi AssetApi) AssetDeleteEndpoint(c echo.Context) error { split := strings.Split(id, ",") for i := range split { if err := service.AssetService.DeleteById(split[i]); err != nil { + recordOperationLog(c, "asset-del", "删除资产: "+id, "failed", err.Error()) return err } } + recordOperationLog(c, "asset-del", "删除资产: "+id, "success", "") return Success(c, nil) } diff --git a/server/api/asset_group.go b/server/api/asset_group.go index 2cc3f0c58..da6af0e87 100644 --- a/server/api/asset_group.go +++ b/server/api/asset_group.go @@ -127,7 +127,7 @@ func buildAssetTree(assets []model.Asset, groups []model.AssetGroup, groupId str node := maps.Map{ "id": g.ID, "name": g.Name, - "key": g.ID, + "key": "group_" + g.ID, "title": g.Name, "value": g.ID, } @@ -144,7 +144,7 @@ func buildAssetTree(assets []model.Asset, groups []model.AssetGroup, groupId str nodes = append(nodes, maps.Map{ "id": a.ID, "name": a.Name, - "key": a.ID, + "key": "asset_" + a.ID, "title": a.Name, "value": a.ID, "isLeaf": true, diff --git a/server/api/filesystem_api.go b/server/api/filesystem_api.go index abb95e33b..da44384ab 100644 --- a/server/api/filesystem_api.go +++ b/server/api/filesystem_api.go @@ -1,10 +1,15 @@ package api import ( + "context" "fmt" + "next-terminal/server/common" "next-terminal/server/common/maps" "next-terminal/server/global/session" "next-terminal/server/log" + "next-terminal/server/model" + "next-terminal/server/repository" + "next-terminal/server/utils" "os" "path" "strconv" @@ -17,6 +22,28 @@ import ( type FileSystemApi struct{} +func (api FileSystemApi) recordFilesystemLog(ctx context.Context, sessionId, action, fileName string) { + sess, err := repository.SessionRepository.FindById(ctx, sessionId) + if err != nil { + log.Error("Failed to find session for filesystem log", log.NamedError("err", err)) + return + } + + filesystemLog := &model.FilesystemLog{ + ID: utils.UUID(), + AssetId: sess.AssetId, + SessionId: sessionId, + UserId: sess.Creator, + Action: action, + FileName: fileName, + Created: common.NowJsonTime(), + } + + if err := repository.FilesystemLogRepository.Create(ctx, filesystemLog); err != nil { + log.Error("Failed to create filesystem log", log.NamedError("err", err)) + } +} + func (api FileSystemApi) LsEndpoint(c echo.Context) error { sessionId := c.Param("id") dir := c.QueryParam("dir") @@ -84,6 +111,8 @@ func (api FileSystemApi) MkdirEndpoint(c echo.Context) error { return Fail(c, -1, "failed to create directory: "+err.Error()) } + go api.recordFilesystemLog(context.TODO(), sessionId, "mkdir", dir) + return Success(c, nil) } @@ -110,6 +139,8 @@ func (api FileSystemApi) TouchEndpoint(c echo.Context) error { } f.Close() + go api.recordFilesystemLog(context.TODO(), sessionId, "touch", filename) + return Success(c, nil) } @@ -150,6 +181,8 @@ func (api FileSystemApi) RmEndpoint(c echo.Context) error { } } + go api.recordFilesystemLog(context.TODO(), sessionId, "rm", filename) + return Success(c, nil) } @@ -200,6 +233,8 @@ func (api FileSystemApi) RenameEndpoint(c echo.Context) error { return Fail(c, -1, "failed to rename: "+err.Error()) } + go api.recordFilesystemLog(context.TODO(), sessionId, "rename", oldName+" -> "+newName) + return Success(c, nil) } @@ -238,6 +273,8 @@ func (api FileSystemApi) EditEndpoint(c echo.Context) error { return Fail(c, -1, "failed to write file: "+err.Error()) } + go api.recordFilesystemLog(context.TODO(), sessionId, "edit", req.Filename) + return Success(c, nil) } @@ -269,6 +306,8 @@ func (api FileSystemApi) ReadEndpoint(c echo.Context) error { return Fail(c, -1, "failed to read file: "+err.Error()) } + go api.recordFilesystemLog(context.TODO(), sessionId, "read", filename) + return Success(c, maps.Map{ "content": content, "path": filename, @@ -322,6 +361,8 @@ func (api FileSystemApi) ChmodEndpoint(c echo.Context) error { return Fail(c, -1, "failed to chmod: "+err.Error()) } + go api.recordFilesystemLog(context.TODO(), sessionId, "chmod", filename+" ("+modeStr+")") + return Success(c, nil) } @@ -369,6 +410,8 @@ func (api FileSystemApi) DownloadEndpoint(c echo.Context) error { return Fail(c, -1, "failed to stat file: "+err.Error()) } + go api.recordFilesystemLog(context.TODO(), sessionId, "download", filename) + c.Response().Header().Set("Content-Disposition", "attachment; filename="+path.Base(filename)) c.Response().Header().Set("Content-Type", "application/octet-stream") c.Response().Header().Set("Content-Length", strconv.FormatInt(stat.Size(), 10)) @@ -425,6 +468,8 @@ func (api FileSystemApi) UploadEndpoint(c echo.Context) error { } } + go api.recordFilesystemLog(context.TODO(), sessionId, "upload", destPath) + return Success(c, maps.Map{ "path": destPath, "size": file.Size, diff --git a/server/api/portal_api.go b/server/api/portal_api.go index 6d21f0a25..aa7bb7745 100644 --- a/server/api/portal_api.go +++ b/server/api/portal_api.go @@ -65,10 +65,10 @@ func (api PortalApi) AssetsTreeEndpoint(c echo.Context) error { tree := make([]maps.Map, 0) for _, g := range groups { node := maps.Map{ - "key": g.ID, + "key": "group_" + g.ID, "title": g.Name, "isLeaf": false, - "children": buildPortalAssetChildren(items, g.ID, keyword), + "children": []interface{}{}, } tree = append(tree, node) } @@ -77,7 +77,7 @@ func (api PortalApi) AssetsTreeEndpoint(c echo.Context) error { continue } node := maps.Map{ - "key": a.ID, + "key": "asset_" + a.ID, "title": a.Name, "isLeaf": true, "extra": maps.Map{ @@ -93,29 +93,6 @@ func (api PortalApi) AssetsTreeEndpoint(c echo.Context) error { return Success(c, tree) } -func buildPortalAssetChildren(assets []model.Asset, groupId, keyword string) []maps.Map { - children := make([]maps.Map, 0) - for _, a := range assets { - if keyword != "" && !containsKeyword(a.Name, keyword) { - continue - } - node := maps.Map{ - "key": a.ID, - "title": a.Name, - "isLeaf": true, - "extra": maps.Map{ - "protocol": a.Protocol, - "logo": "", - "status": "unknown", - "network": a.IP, - "wolEnabled": false, - }, - } - children = append(children, node) - } - return children -} - func containsKeyword(name, keyword string) bool { if keyword == "" { return true @@ -139,7 +116,7 @@ func (api PortalApi) WebsitesTreeEndpoint(c echo.Context) error { continue } node := maps.Map{ - "key": w.ID, + "key": "website_" + w.ID, "title": w.Name, "isLeaf": true, "extra": maps.Map{ @@ -159,7 +136,7 @@ func (api PortalApi) AssetsGroupTreeEndpoint(c echo.Context) error { tree := make([]maps.Map, 0) for _, g := range groups { node := maps.Map{ - "key": g.ID, + "key": "group_" + g.ID, "title": g.Name, "isLeaf": false, "children": []interface{}{}, @@ -186,7 +163,8 @@ func (api PortalApi) CreateSessionEndpoint(c echo.Context) error { account, _ := GetCurrentAccount(c) - s, err := service.SessionService.Create(c.RealIP(), assetId, nt.Native, account) + clientIP := service.PropertyService.GetClientIP(c) + s, err := service.SessionService.Create(clientIP, assetId, nt.Native, account) if err != nil { return err } @@ -201,7 +179,16 @@ func (api PortalApi) CreateSessionEndpoint(c echo.Context) error { "id": s.ID, "protocol": s.Protocol, "assetName": assetName, - "strategy": maps.Map{}, + "strategy": maps.Map{ + "upload": s.Upload == "1", + "download": s.Download == "1", + "delete": s.Delete == "1", + "rename": s.Rename == "1", + "edit": s.Edit == "1", + "copy": s.Copy == "1", + "paste": s.Paste == "1", + "fileSystem": s.FileSystem == "1", + }, "url": "", "watermark": maps.Map{}, "readonly": false, diff --git a/server/api/session.go b/server/api/session.go index 38a23a922..0c7c81683 100644 --- a/server/api/session.go +++ b/server/api/session.go @@ -202,7 +202,8 @@ func (api SessionApi) SessionCreateEndpoint(c echo.Context) error { user, _ := GetCurrentAccount(c) - s, err := service.SessionService.Create(c.RealIP(), assetId, mode, user) + clientIP := service.PropertyService.GetClientIP(c) + s, err := service.SessionService.Create(clientIP, assetId, mode, user) if err != nil { return err } diff --git a/server/api/stub_apis.go b/server/api/stub_apis.go index 4688c773f..7a76e5ac6 100644 --- a/server/api/stub_apis.go +++ b/server/api/stub_apis.go @@ -2,7 +2,11 @@ package api import ( "context" + "strconv" + "next-terminal/server/common/maps" + "next-terminal/server/model" + "next-terminal/server/repository" "github.com/labstack/echo/v4" ) @@ -10,18 +14,37 @@ import ( type SessionCommandApi struct{} func (api SessionCommandApi) AllEndpoint(c echo.Context) error { - return Success(c, []interface{}{}) + sessionId := c.QueryParam("sessionId") + items, err := repository.SessionCommandRepository.FindBySessionId(context.TODO(), sessionId) + if err != nil { + return err + } + return Success(c, items) } func (api SessionCommandApi) PagingEndpoint(c echo.Context) error { + pageIndex, _ := strconv.Atoi(c.QueryParam("pageIndex")) + pageSize, _ := strconv.Atoi(c.QueryParam("pageSize")) + sessionId := c.QueryParam("sessionId") + + items, total, err := repository.SessionCommandRepository.FindByPage(context.TODO(), pageIndex, pageSize, sessionId) + if err != nil { + return err + } return Success(c, maps.Map{ - "total": 0, - "items": []interface{}{}, + "total": total, + "items": items, }) } func (api SessionCommandApi) GetEndpoint(c echo.Context) error { - return Success(c, nil) + id := c.Param("id") + var item model.SessionCommand + err := repository.SessionCommandRepository.GetDB(context.TODO()).Where("id = ?", id).First(&item).Error + if err != nil { + return err + } + return Success(c, item) } type OperationLogApi struct{} @@ -31,17 +54,55 @@ func (api OperationLogApi) AllEndpoint(c echo.Context) error { } func (api OperationLogApi) PagingEndpoint(c echo.Context) error { + pageIndex, _ := strconv.Atoi(c.QueryParam("pageIndex")) + pageSize, _ := strconv.Atoi(c.QueryParam("pageSize")) + accountName := c.QueryParam("accountName") + action := c.QueryParam("action") + status := c.QueryParam("status") + order := c.QueryParam("order") + field := c.QueryParam("field") + + items, total, err := repository.OperationLogRepository.FindByPage(context.TODO(), pageIndex, pageSize, accountName, action, status, order, field) + if err != nil { + return err + } + + result := make([]maps.Map, 0) + for _, item := range items { + result = append(result, maps.Map{ + "id": item.ID, + "accountId": item.AccountId, + "accountName": item.AccountName, + "action": item.Action, + "content": item.Content, + "ip": item.IP, + "region": item.Region, + "userAgent": item.UserAgent, + "status": item.Status, + "errorMessage": item.ErrorMessage, + "remark": item.Remark, + "createdAt": item.Created, + }) + } + return Success(c, maps.Map{ - "total": 0, - "items": []interface{}{}, + "total": total, + "items": result, }) } func (api OperationLogApi) ClearEndpoint(c echo.Context) error { + if err := repository.OperationLogRepository.DeleteAll(context.TODO()); err != nil { + return err + } return Success(c, nil) } func (api OperationLogApi) DeleteEndpoint(c echo.Context) error { + id := c.Param("id") + if err := repository.OperationLogRepository.DeleteById(context.TODO(), id); err != nil { + return err + } return Success(c, nil) } @@ -111,17 +172,57 @@ func (api FilesystemLogApi) AllEndpoint(c echo.Context) error { } func (api FilesystemLogApi) PagingEndpoint(c echo.Context) error { + pageIndex, _ := strconv.Atoi(c.QueryParam("pageIndex")) + pageSize, _ := strconv.Atoi(c.QueryParam("pageSize")) + action := c.QueryParam("action") + + items, total, err := repository.FilesystemLogRepository.FindByPage(context.TODO(), pageIndex, pageSize, action) + if err != nil { + return err + } + + result := make([]maps.Map, 0) + for _, item := range items { + var assetName, userName string + if item.AssetId != "" { + asset, _ := repository.AssetRepository.FindById(context.TODO(), item.AssetId) + assetName = asset.Name + } + if item.UserId != "" { + user, _ := repository.UserRepository.FindById(context.TODO(), item.UserId) + userName = user.Username + } + result = append(result, maps.Map{ + "id": item.ID, + "assetId": item.AssetId, + "sessionId": item.SessionId, + "userId": item.UserId, + "action": item.Action, + "fileName": item.FileName, + "createdAt": item.Created, + "assetName": assetName, + "userName": userName, + }) + } + return Success(c, maps.Map{ - "total": 0, - "items": []interface{}{}, + "total": total, + "items": result, }) } func (api FilesystemLogApi) DeleteEndpoint(c echo.Context) error { + id := c.Param("id") + if err := repository.FilesystemLogRepository.DeleteById(context.TODO(), id); err != nil { + return err + } return Success(c, nil) } func (api FilesystemLogApi) ClearEndpoint(c echo.Context) error { + if err := repository.FilesystemLogRepository.DeleteAll(context.TODO()); err != nil { + return err + } return Success(c, nil) } @@ -161,17 +262,56 @@ func (api AccessLogApi) AllEndpoint(c echo.Context) error { } func (api AccessLogApi) PagingEndpoint(c echo.Context) error { + pageIndex, _ := strconv.Atoi(c.QueryParam("pageIndex")) + pageSize, _ := strconv.Atoi(c.QueryParam("pageSize")) + domain := c.QueryParam("domain") + websiteId := c.QueryParam("websiteId") + accountId := c.QueryParam("accountId") + + items, total, err := repository.AccessLogRepository.FindByPage(context.TODO(), pageIndex, pageSize, domain, websiteId, accountId) + if err != nil { + return err + } + + result := make([]maps.Map, 0) + for _, item := range items { + result = append(result, maps.Map{ + "id": item.ID, + "domain": item.Domain, + "websiteId": item.WebsiteId, + "accountId": item.AccountId, + "method": item.Method, + "uri": item.Uri, + "statusCode": item.StatusCode, + "responseSize": item.ResponseSize, + "clientIp": item.ClientIp, + "region": item.Region, + "userAgent": item.UserAgent, + "referer": item.Referer, + "requestTime": item.RequestTime, + "responseTime": item.ResponseTime, + "createdAt": item.Created, + }) + } + return Success(c, maps.Map{ - "total": 0, - "items": []interface{}{}, + "total": total, + "items": result, }) } func (api AccessLogApi) DeleteEndpoint(c echo.Context) error { + id := c.Param("id") + if err := repository.AccessLogRepository.DeleteById(context.TODO(), id); err != nil { + return err + } return Success(c, nil) } func (api AccessLogApi) ClearEndpoint(c echo.Context) error { + if err := repository.AccessLogRepository.DeleteAll(context.TODO()); err != nil { + return err + } return Success(c, nil) } diff --git a/server/api/stub_apis2.go b/server/api/stub_apis2.go index 3ddd65b88..a01d54ac8 100644 --- a/server/api/stub_apis2.go +++ b/server/api/stub_apis2.go @@ -537,13 +537,59 @@ func (api DbWorkOrderApi) DeleteEndpoint(c echo.Context) error { type DatabaseSQLLogApi struct{} func (api DatabaseSQLLogApi) PagingEndpoint(c echo.Context) error { + pageIndex, _ := strconv.Atoi(c.QueryParam("pageIndex")) + pageSize, _ := strconv.Atoi(c.QueryParam("pageSize")) + assetId := c.QueryParam("assetId") + userId := c.QueryParam("userId") + status := c.QueryParam("status") + source := c.QueryParam("source") + order := c.QueryParam("order") + field := c.QueryParam("field") + + items, total, err := repository.DatabaseSQLLogRepository.FindByPage(context.TODO(), pageIndex, pageSize, assetId, userId, status, source, order, field) + if err != nil { + return err + } + + result := make([]maps.Map, 0) + for _, item := range items { + var assetName, userName string + if item.AssetId != "" { + asset, _ := repository.DatabaseAssetRepository.FindById(context.TODO(), item.AssetId) + assetName = asset.Name + } + if item.UserId != "" { + user, _ := repository.UserRepository.FindById(context.TODO(), item.UserId) + userName = user.Username + } + result = append(result, maps.Map{ + "id": item.ID, + "assetId": item.AssetId, + "assetName": assetName, + "database": item.Database, + "userId": item.UserId, + "userName": userName, + "clientIp": item.ClientIP, + "sql": item.SQL, + "durationMs": item.DurationMs, + "rowsAffected": item.RowsAffected, + "status": item.Status, + "errorMessage": item.ErrorMessage, + "source": item.Source, + "createdAt": item.Created, + }) + } + return Success(c, maps.Map{ - "total": 0, - "items": []interface{}{}, + "total": total, + "items": result, }) } func (api DatabaseSQLLogApi) ClearEndpoint(c echo.Context) error { + if err := repository.DatabaseSQLLogRepository.DeleteAll(context.TODO()); err != nil { + return err + } return Success(c, nil) } diff --git a/server/api/term.go b/server/api/term.go index 6bf778631..086d8b9a3 100644 --- a/server/api/term.go +++ b/server/api/term.go @@ -188,7 +188,7 @@ func (api WebTerminalApi) SshEndpoint(c echo.Context) error { if err != nil { service.SessionService.CloseSessionById(sessionId, TunnelClosed, "远程连接已关闭") } else { - _ = termHandler.SendMessageToWebSocket(dto.NewMessage(Ping, "")) + _ = termHandler.SendMessageToWebSocket(dto.NewMessage(Ping, msg.Content)) } } @@ -358,7 +358,7 @@ func (api WebTerminalApi) AccessTerminalEndpoint(c echo.Context) error { termHandler.Write([]byte(msg.Content)) case Ping: termHandler.SendRequest() - termHandler.SendMessageToWebSocket(dto.NewMessage(Ping, "")) + termHandler.SendMessageToWebSocket(dto.NewMessage(Ping, msg.Content)) } } return nil diff --git a/server/api/term_handler.go b/server/api/term_handler.go index 33741c0f7..c602f047b 100644 --- a/server/api/term_handler.go +++ b/server/api/term_handler.go @@ -3,18 +3,27 @@ package api import ( "bytes" "context" + "strings" "sync" "time" "unicode/utf8" "github.com/gorilla/websocket" + "next-terminal/server/common" "next-terminal/server/common/term" "next-terminal/server/dto" "next-terminal/server/global/session" + "next-terminal/server/model" + "next-terminal/server/repository" + "next-terminal/server/utils" ) +const maxOutputLength = 10000 + type TermHandler struct { sessionId string + userId string + assetId string isRecording bool webSocket *websocket.Conn nextTerminal *term.NextTerminal @@ -24,6 +33,10 @@ type TermHandler struct { tick *time.Ticker mutex sync.Mutex buf bytes.Buffer + commandBuf bytes.Buffer + outputBuf bytes.Buffer + lastCommand *model.SessionCommand + outputMutex sync.Mutex } func NewTermHandler(userId, assetId, sessionId string, isRecording bool, ws *websocket.Conn, nextTerminal *term.NextTerminal) *TermHandler { @@ -32,6 +45,8 @@ func NewTermHandler(userId, assetId, sessionId string, isRecording bool, ws *web return &TermHandler{ sessionId: sessionId, + userId: userId, + assetId: assetId, isRecording: isRecording, webSocket: ws, nextTerminal: nextTerminal, @@ -48,9 +63,9 @@ func (r *TermHandler) Start() { } func (r *TermHandler) Stop() { - // 会话结束时记录最后一个命令 r.tick.Stop() r.cancel() + r.saveLastCommandOutput() } func (r *TermHandler) readFormTunnel() { @@ -86,8 +101,8 @@ func (r *TermHandler) writeToWebsocket() { if r.isRecording && r.nextTerminal.Recorder != nil { _ = r.nextTerminal.Recorder.WriteData(s) } - // 监控 SendObData(r.sessionId, s) + r.collectOutput(s) r.buf.Reset() case data := <-r.dataChan: if data != utf8.RuneError { @@ -101,12 +116,111 @@ func (r *TermHandler) writeToWebsocket() { } } +func (r *TermHandler) collectOutput(output string) { + r.outputMutex.Lock() + defer r.outputMutex.Unlock() + if r.outputBuf.Len()+len(output) <= maxOutputLength { + r.outputBuf.WriteString(output) + } +} + +func (r *TermHandler) getAndClearOutput() string { + r.outputMutex.Lock() + defer r.outputMutex.Unlock() + output := r.outputBuf.String() + r.outputBuf.Reset() + return output +} + +func (r *TermHandler) saveLastCommandOutput() { + r.outputMutex.Lock() + defer r.outputMutex.Unlock() + if r.lastCommand != nil && r.outputBuf.Len() > 0 { + output := cleanOutput(r.outputBuf.String()) + if len(output) > maxOutputLength { + output = output[:maxOutputLength] + } + r.lastCommand.Output = output + _ = repository.SessionCommandRepository.Create(context.TODO(), r.lastCommand) + r.lastCommand = nil + r.outputBuf.Reset() + } +} + +func cleanOutput(output string) string { + output = strings.ReplaceAll(output, "\x1b[0m", "") + output = strings.ReplaceAll(output, "\x1b[?2004h", "") + output = strings.ReplaceAll(output, "\x1b[?2004l", "") + var result strings.Builder + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line != "" { + result.WriteString(line) + result.WriteString("\n") + } + } + return strings.TrimSpace(result.String()) +} + func (r *TermHandler) Write(input []byte) error { - // 正常的字符输入 + for _, b := range input { + switch b { + case 13: // Enter key + command := strings.TrimSpace(r.commandBuf.String()) + if command != "" { + r.saveLastCommandOutput() + r.lastCommand = &model.SessionCommand{ + ID: utils.UUID(), + SessionId: r.sessionId, + RiskLevel: detectRiskLevel(command), + Command: command, + Output: "", + Created: common.NowJsonTime(), + } + } + r.commandBuf.Reset() + case 127, 8: // Backspace, Delete + if r.commandBuf.Len() > 0 { + r.commandBuf.Truncate(r.commandBuf.Len() - 1) + } + case 27: // Escape sequence (arrow keys, etc.) + default: + if b >= 32 && b < 127 { // Printable ASCII + r.commandBuf.WriteByte(b) + } + } + } _, err := r.nextTerminal.Write(input) return err } +func detectRiskLevel(command string) int { + highRiskPatterns := []string{ + "rm -rf /", "rm -rf /*", "mkfs", "dd if=", "> /dev/sd", + "chmod -R 777", "chown -R", ":(){ :|:& };:", + } + mediumRiskPatterns := []string{ + "rm -rf", "rm -r", "passwd", "useradd", "userdel", + "chmod 777", "chmod -R", "chown", "wget", "curl", + "apt-get", "yum install", "dnf install", "pacman", + "systemctl stop", "systemctl disable", "service stop", + "iptables -F", "ufw disable", "setenforce 0", + } + + lowerCmd := strings.ToLower(command) + for _, pattern := range highRiskPatterns { + if strings.Contains(lowerCmd, strings.ToLower(pattern)) { + return 2 + } + } + for _, pattern := range mediumRiskPatterns { + if strings.Contains(lowerCmd, strings.ToLower(pattern)) { + return 1 + } + } + return 0 +} + func (r *TermHandler) WindowChange(h int, w int) error { return r.nextTerminal.WindowChange(h, w) } diff --git a/server/api/user.go b/server/api/user.go index 768f279ff..f8549f483 100644 --- a/server/api/user.go +++ b/server/api/user.go @@ -25,9 +25,11 @@ func (userApi UserApi) CreateEndpoint(c echo.Context) (err error) { } if err := service.UserService.CreateUser(item); err != nil { + recordOperationLog(c, "user-add", "创建用户: "+item.Username, "failed", err.Error()) return err } + recordOperationLog(c, "user-add", "创建用户: "+item.Username, "success", "") return Success(c, item) } @@ -68,9 +70,11 @@ func (userApi UserApi) UpdateEndpoint(c echo.Context) error { } if err := service.UserService.UpdateUser(id, item); err != nil { + recordOperationLog(c, "user-edit", "更新用户: "+item.Username, "failed", err.Error()) return err } + recordOperationLog(c, "user-edit", "更新用户: "+item.Username, "success", "") return Success(c, nil) } @@ -83,9 +87,11 @@ func (userApi UserApi) UpdateStatusEndpoint(c echo.Context) error { } if err := service.UserService.UpdateStatusById(id, status); err != nil { + recordOperationLog(c, "user-edit", "更新用户状态", "failed", err.Error()) return err } + recordOperationLog(c, "user-edit", "更新用户状态", "success", "") return Success(c, nil) } @@ -102,10 +108,12 @@ func (userApi UserApi) DeleteEndpoint(c echo.Context) error { return Fail(c, -1, "不允许删除自身账户") } if err := service.UserService.DeleteUserById(userId); err != nil { + recordOperationLog(c, "user-del", "删除用户: "+ids, "failed", err.Error()) return err } } + recordOperationLog(c, "user-del", "删除用户: "+ids, "success", "") return Success(c, nil) } diff --git a/server/api/website_api.go b/server/api/website_api.go index 61719cafc..0dbcd1fc4 100644 --- a/server/api/website_api.go +++ b/server/api/website_api.go @@ -169,7 +169,7 @@ func buildWebsiteGroupTree(groups []model.WebsiteGroup, parentId string) []maps. "id": g.ID, "name": g.Name, "title": g.Name, - "key": g.ID, + "key": "wgroup_" + g.ID, "value": g.ID, } children := buildWebsiteGroupTree(groups, g.ID) diff --git a/server/app/middleware/tcpwall.go b/server/app/middleware/tcpwall.go index b3c3aa266..b2b9704e0 100644 --- a/server/app/middleware/tcpwall.go +++ b/server/app/middleware/tcpwall.go @@ -3,6 +3,7 @@ package middleware import ( "net" "next-terminal/server/common/nt" + "next-terminal/server/service" "strings" "next-terminal/server/api" @@ -20,7 +21,7 @@ func TcpWall(next echo.HandlerFunc) echo.HandlerFunc { return next(c) } - ip := c.RealIP() + ip := service.PropertyService.GetClientIP(c) var pass = true diff --git a/server/env/db.go b/server/env/db.go index 9a616bf26..2817325f9 100644 --- a/server/env/db.go +++ b/server/env/db.go @@ -55,7 +55,7 @@ func setupDB() *gorm.DB { &model.Role{}, &model.RoleMenuRef{}, &model.UserRoleRef{}, &model.LoginPolicy{}, &model.LoginPolicyUserRef{}, &model.TimePeriod{}, &model.StorageLog{}, &model.Authorised{}, &model.Logo{}, &model.AssetGroup{}, - &model.AgentGateway{}, &model.SshGateway{}, &model.GatewayGroup{}, &model.Website{}, &model.Certificate{}, &model.Snippet{}, &model.SessionAudit{}, &model.Department{}, &model.UserDepartmentRef{}, &model.DatabaseAsset{}, &model.CommandFilter{}, &model.CommandFilterRule{}, &model.AuthorisedAsset{}, &model.AuthorisedDatabaseAsset{}, &model.AuthorisedWebsite{}, &model.WebsiteGroup{}); err != nil { + &model.AgentGateway{}, &model.SshGateway{}, &model.GatewayGroup{}, &model.Website{}, &model.Certificate{}, &model.Snippet{}, &model.SessionAudit{}, &model.Department{}, &model.UserDepartmentRef{}, &model.DatabaseAsset{}, &model.CommandFilter{}, &model.CommandFilterRule{}, &model.AuthorisedAsset{}, &model.AuthorisedDatabaseAsset{}, &model.AuthorisedWebsite{}, &model.WebsiteGroup{}, &model.AccessLog{}, &model.FilesystemLog{}, &model.OperationLog{}, &model.DatabaseSQLLog{}, &model.SessionCommand{}); err != nil { panic(fmt.Errorf("初始化数据库表结构异常: %v", err.Error())) } return db diff --git a/server/model/access_log.go b/server/model/access_log.go new file mode 100644 index 000000000..1eba091af --- /dev/null +++ b/server/model/access_log.go @@ -0,0 +1,25 @@ +package model + +import "next-terminal/server/common" + +type AccessLog struct { + ID string `gorm:"primary_key,type:varchar(36)" json:"id"` + Domain string `gorm:"type:varchar(500);index" json:"domain"` + WebsiteId string `gorm:"type:varchar(36);index" json:"websiteId"` + AccountId string `gorm:"type:varchar(36);index" json:"accountId"` + Method string `gorm:"type:varchar(10)" json:"method"` + Uri string `gorm:"type:varchar(2000)" json:"uri"` + StatusCode int `json:"statusCode"` + ResponseSize int64 `json:"responseSize"` + ClientIp string `gorm:"type:varchar(50);index" json:"clientIp"` + Region string `gorm:"type:varchar(100)" json:"region"` + UserAgent string `gorm:"type:varchar(500)" json:"userAgent"` + Referer string `gorm:"type:varchar(2000)" json:"referer"` + RequestTime int `json:"requestTime"` + ResponseTime int `json:"responseTime"` + Created common.JsonTime `gorm:"type:datetime;index" json:"createdAt"` +} + +func (r *AccessLog) TableName() string { + return "access_logs" +} diff --git a/server/model/database_sql_log.go b/server/model/database_sql_log.go new file mode 100644 index 000000000..2b77fd524 --- /dev/null +++ b/server/model/database_sql_log.go @@ -0,0 +1,24 @@ +package model + +import ( + "next-terminal/server/common" +) + +type DatabaseSQLLog struct { + ID string `gorm:"primary_key,type:varchar(36)" json:"id"` + AssetId string `gorm:"index,type:varchar(36)" json:"assetId"` + Database string `gorm:"type:varchar(200)" json:"database"` + UserId string `gorm:"index,type:varchar(36)" json:"userId"` + ClientIP string `gorm:"type:varchar(50);index" json:"clientIp"` + SQL string `gorm:"type:text" json:"sql"` + DurationMs int `json:"durationMs"` + RowsAffected int `json:"rowsAffected"` + Status string `gorm:"type:varchar(20);index" json:"status"` + ErrorMessage string `gorm:"type:text" json:"errorMessage"` + Source string `gorm:"type:varchar(50);index" json:"source"` + Created common.JsonTime `gorm:"type:datetime;index" json:"createdAt"` +} + +func (r *DatabaseSQLLog) TableName() string { + return "database_sql_logs" +} diff --git a/server/model/filesystem_log.go b/server/model/filesystem_log.go new file mode 100644 index 000000000..1e3ac8e42 --- /dev/null +++ b/server/model/filesystem_log.go @@ -0,0 +1,19 @@ +package model + +import ( + "next-terminal/server/common" +) + +type FilesystemLog struct { + ID string `gorm:"primary_key,type:varchar(36)" json:"id"` + AssetId string `gorm:"index,type:varchar(36)" json:"assetId"` + SessionId string `gorm:"index,type:varchar(36)" json:"sessionId"` + UserId string `gorm:"index,type:varchar(36)" json:"userId"` + Action string `gorm:"type:varchar(50);index" json:"action"` + FileName string `gorm:"type:varchar(500)" json:"fileName"` + Created common.JsonTime `gorm:"type:datetime;index" json:"createdAt"` +} + +func (r *FilesystemLog) TableName() string { + return "filesystem_logs" +} diff --git a/server/model/operation_log.go b/server/model/operation_log.go new file mode 100644 index 000000000..ebd3b6f9c --- /dev/null +++ b/server/model/operation_log.go @@ -0,0 +1,24 @@ +package model + +import ( + "next-terminal/server/common" +) + +type OperationLog struct { + ID string `gorm:"primary_key,type:varchar(36)" json:"id"` + AccountId string `gorm:"index,type:varchar(36)" json:"accountId"` + AccountName string `gorm:"type:varchar(200)" json:"accountName"` + Action string `gorm:"type:varchar(100);index" json:"action"` + Content string `gorm:"type:text" json:"content"` + IP string `gorm:"type:varchar(50);index" json:"ip"` + Region string `gorm:"type:varchar(200)" json:"region"` + UserAgent string `gorm:"type:varchar(500)" json:"userAgent"` + Status string `gorm:"type:varchar(20);index" json:"status"` + ErrorMessage string `gorm:"type:text" json:"errorMessage"` + Remark string `gorm:"type:varchar(500)" json:"remark"` + Created common.JsonTime `gorm:"type:datetime;index" json:"createdAt"` +} + +func (r *OperationLog) TableName() string { + return "operation_logs" +} diff --git a/server/model/session_command.go b/server/model/session_command.go new file mode 100644 index 000000000..c47054c42 --- /dev/null +++ b/server/model/session_command.go @@ -0,0 +1,18 @@ +package model + +import ( + "next-terminal/server/common" +) + +type SessionCommand struct { + ID string `gorm:"primary_key,type:varchar(36)" json:"id"` + SessionId string `gorm:"index,type:varchar(36)" json:"sessionId"` + RiskLevel int `json:"riskLevel"` + Command string `gorm:"type:text" json:"command"` + Output string `gorm:"type:text" json:"output"` + Created common.JsonTime `gorm:"type:datetime;index" json:"createdAt"` +} + +func (r *SessionCommand) TableName() string { + return "session_commands" +} diff --git a/server/repository/access_log.go b/server/repository/access_log.go new file mode 100644 index 000000000..fc7c879ae --- /dev/null +++ b/server/repository/access_log.go @@ -0,0 +1,59 @@ +package repository + +import ( + "context" + + "next-terminal/server/model" +) + +var AccessLogRepository = new(accessLogRepository) + +type accessLogRepository struct { + baseRepository +} + +func (r accessLogRepository) Create(c context.Context, o *model.AccessLog) error { + return r.GetDB(c).Create(o).Error +} + +func (r accessLogRepository) FindById(c context.Context, id string) (o model.AccessLog, err error) { + err = r.GetDB(c).Where("id = ?", id).First(&o).Error + return +} + +func (r accessLogRepository) FindByPage(c context.Context, pageIndex, pageSize int, domain, websiteId, accountId string) (o []model.AccessLog, total int64, err error) { + db := r.GetDB(c).Model(&model.AccessLog{}) + if domain != "" { + db = db.Where("domain LIKE ?", "%"+domain+"%") + } + if websiteId != "" { + db = db.Where("website_id = ?", websiteId) + } + if accountId != "" { + db = db.Where("account_id = ?", accountId) + } + err = db.Count(&total).Error + if err != nil { + return nil, 0, err + } + err = db.Order("created desc").Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&o).Error + return +} + +func (r accessLogRepository) DeleteById(c context.Context, id string) error { + return r.GetDB(c).Where("id = ?", id).Delete(&model.AccessLog{}).Error +} + +func (r accessLogRepository) DeleteAll(c context.Context) error { + return r.GetDB(c).Where("1 = 1").Delete(&model.AccessLog{}).Error +} + +func (r accessLogRepository) FindOutTimeLog(c context.Context, limit int) (o []model.AccessLog, err error) { + err = r.GetDB(c).Where("strftime('%s', created) < strftime('%s', 'now', '-' || ? || ' days')", limit).Find(&o).Error + return +} + +func (r accessLogRepository) Count(c context.Context) (total int64, err error) { + err = r.GetDB(c).Model(&model.AccessLog{}).Count(&total).Error + return +} diff --git a/server/repository/database_sql_log.go b/server/repository/database_sql_log.go new file mode 100644 index 000000000..f5b524ec3 --- /dev/null +++ b/server/repository/database_sql_log.go @@ -0,0 +1,79 @@ +package repository + +import ( + "context" + "time" + + "next-terminal/server/model" +) + +var DatabaseSQLLogRepository = new(databaseSQLLogRepository) + +type databaseSQLLogRepository struct { + baseRepository +} + +func (r databaseSQLLogRepository) FindByPage(c context.Context, pageIndex, pageSize int, assetId, userId, status, source string, order, field string) (o []model.DatabaseSQLLog, total int64, err error) { + m := model.DatabaseSQLLog{} + db := r.GetDB(c).Table(m.TableName()) + dbCounter := r.GetDB(c).Table(m.TableName()) + + if assetId != "" { + db = db.Where("asset_id = ?", assetId) + dbCounter = dbCounter.Where("asset_id = ?", assetId) + } + + if userId != "" { + db = db.Where("user_id = ?", userId) + dbCounter = dbCounter.Where("user_id = ?", userId) + } + + if status != "" { + db = db.Where("status = ?", status) + dbCounter = dbCounter.Where("status = ?", status) + } + + if source != "" { + db = db.Where("source = ?", source) + dbCounter = dbCounter.Where("source = ?", source) + } + + err = dbCounter.Count(&total).Error + if err != nil { + return nil, 0, err + } + + orderBy := "created desc" + if order != "" && field != "" { + orderBy = field + " " + order + } + + err = db.Order(orderBy).Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&o).Error + if o == nil { + o = make([]model.DatabaseSQLLog, 0) + } + return +} + +func (r databaseSQLLogRepository) Create(c context.Context, o *model.DatabaseSQLLog) error { + return r.GetDB(c).Create(o).Error +} + +func (r databaseSQLLogRepository) DeleteById(c context.Context, id string) error { + return r.GetDB(c).Where("id = ?", id).Delete(&model.DatabaseSQLLog{}).Error +} + +func (r databaseSQLLogRepository) DeleteAll(c context.Context) error { + return r.GetDB(c).Where("1 = 1").Delete(&model.DatabaseSQLLog{}).Error +} + +func (r databaseSQLLogRepository) FindOutTimeLog(c context.Context, dayLimit int) (o []model.DatabaseSQLLog, err error) { + limitTime := time.Now().Add(time.Duration(-dayLimit*24) * time.Hour) + err = r.GetDB(c).Where("created < ?", limitTime).Find(&o).Error + return +} + +func (r databaseSQLLogRepository) Count(c context.Context) (total int64, err error) { + err = r.GetDB(c).Model(&model.DatabaseSQLLog{}).Count(&total).Error + return +} diff --git a/server/repository/filesystem_log.go b/server/repository/filesystem_log.go new file mode 100644 index 000000000..e940b2490 --- /dev/null +++ b/server/repository/filesystem_log.go @@ -0,0 +1,59 @@ +package repository + +import ( + "context" + "time" + + "next-terminal/server/model" +) + +var FilesystemLogRepository = new(filesystemLogRepository) + +type filesystemLogRepository struct { + baseRepository +} + +func (r filesystemLogRepository) FindByPage(c context.Context, pageIndex, pageSize int, action string) (o []model.FilesystemLog, total int64, err error) { + m := model.FilesystemLog{} + db := r.GetDB(c).Table(m.TableName()) + dbCounter := r.GetDB(c).Table(m.TableName()) + + if action != "" { + db = db.Where("action = ?", action) + dbCounter = dbCounter.Where("action = ?", action) + } + + err = dbCounter.Count(&total).Error + if err != nil { + return nil, 0, err + } + + err = db.Order("created desc").Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&o).Error + if o == nil { + o = make([]model.FilesystemLog, 0) + } + return +} + +func (r filesystemLogRepository) Create(c context.Context, o *model.FilesystemLog) error { + return r.GetDB(c).Create(o).Error +} + +func (r filesystemLogRepository) DeleteById(c context.Context, id string) error { + return r.GetDB(c).Where("id = ?", id).Delete(&model.FilesystemLog{}).Error +} + +func (r filesystemLogRepository) DeleteAll(c context.Context) error { + return r.GetDB(c).Where("1 = 1").Delete(&model.FilesystemLog{}).Error +} + +func (r filesystemLogRepository) FindOutTimeLog(c context.Context, dayLimit int) (o []model.FilesystemLog, err error) { + limitTime := time.Now().Add(time.Duration(-dayLimit*24) * time.Hour) + err = r.GetDB(c).Where("created < ?", limitTime).Find(&o).Error + return +} + +func (r filesystemLogRepository) Count(c context.Context) (total int64, err error) { + err = r.GetDB(c).Model(&model.FilesystemLog{}).Count(&total).Error + return +} diff --git a/server/repository/operation_log.go b/server/repository/operation_log.go new file mode 100644 index 000000000..8abcbb1f6 --- /dev/null +++ b/server/repository/operation_log.go @@ -0,0 +1,74 @@ +package repository + +import ( + "context" + "time" + + "next-terminal/server/model" +) + +var OperationLogRepository = new(operationLogRepository) + +type operationLogRepository struct { + baseRepository +} + +func (r operationLogRepository) FindByPage(c context.Context, pageIndex, pageSize int, accountName, action, status string, order, field string) (o []model.OperationLog, total int64, err error) { + m := model.OperationLog{} + db := r.GetDB(c).Table(m.TableName()) + dbCounter := r.GetDB(c).Table(m.TableName()) + + if accountName != "" { + db = db.Where("account_name like ?", "%"+accountName+"%") + dbCounter = dbCounter.Where("account_name like ?", "%"+accountName+"%") + } + + if action != "" { + db = db.Where("action = ?", action) + dbCounter = dbCounter.Where("action = ?", action) + } + + if status != "" { + db = db.Where("status = ?", status) + dbCounter = dbCounter.Where("status = ?", status) + } + + err = dbCounter.Count(&total).Error + if err != nil { + return nil, 0, err + } + + orderBy := "created desc" + if order != "" && field != "" { + orderBy = field + " " + order + } + + err = db.Order(orderBy).Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&o).Error + if o == nil { + o = make([]model.OperationLog, 0) + } + return +} + +func (r operationLogRepository) Create(c context.Context, o *model.OperationLog) error { + return r.GetDB(c).Create(o).Error +} + +func (r operationLogRepository) DeleteById(c context.Context, id string) error { + return r.GetDB(c).Where("id = ?", id).Delete(&model.OperationLog{}).Error +} + +func (r operationLogRepository) DeleteAll(c context.Context) error { + return r.GetDB(c).Where("1 = 1").Delete(&model.OperationLog{}).Error +} + +func (r operationLogRepository) FindOutTimeLog(c context.Context, dayLimit int) (o []model.OperationLog, err error) { + limitTime := time.Now().Add(time.Duration(-dayLimit*24) * time.Hour) + err = r.GetDB(c).Where("created < ?", limitTime).Find(&o).Error + return +} + +func (r operationLogRepository) Count(c context.Context) (total int64, err error) { + err = r.GetDB(c).Model(&model.OperationLog{}).Count(&total).Error + return +} diff --git a/server/repository/session_command.go b/server/repository/session_command.go new file mode 100644 index 000000000..a62809375 --- /dev/null +++ b/server/repository/session_command.go @@ -0,0 +1,48 @@ +package repository + +import ( + "context" + + "next-terminal/server/model" +) + +var SessionCommandRepository = new(sessionCommandRepository) + +type sessionCommandRepository struct { + baseRepository +} + +func (r sessionCommandRepository) FindBySessionId(c context.Context, sessionId string) (o []model.SessionCommand, err error) { + err = r.GetDB(c).Where("session_id = ?", sessionId).Order("created asc").Find(&o).Error + return +} + +func (r sessionCommandRepository) FindByPage(c context.Context, pageIndex, pageSize int, sessionId string) (o []model.SessionCommand, total int64, err error) { + m := model.SessionCommand{} + db := r.GetDB(c).Table(m.TableName()) + dbCounter := r.GetDB(c).Table(m.TableName()) + + if sessionId != "" { + db = db.Where("session_id = ?", sessionId) + dbCounter = dbCounter.Where("session_id = ?", sessionId) + } + + err = dbCounter.Count(&total).Error + if err != nil { + return nil, 0, err + } + + err = db.Order("created desc").Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&o).Error + if o == nil { + o = make([]model.SessionCommand, 0) + } + return +} + +func (r sessionCommandRepository) Create(c context.Context, o *model.SessionCommand) error { + return r.GetDB(c).Create(o).Error +} + +func (r sessionCommandRepository) DeleteBySessionId(c context.Context, sessionId string) error { + return r.GetDB(c).Where("session_id = ?", sessionId).Delete(&model.SessionCommand{}).Error +} diff --git a/server/service/database_sql_log.go b/server/service/database_sql_log.go new file mode 100644 index 000000000..1253dc42c --- /dev/null +++ b/server/service/database_sql_log.go @@ -0,0 +1,47 @@ +package service + +import ( + "context" + + "next-terminal/server/common" + "next-terminal/server/model" + "next-terminal/server/repository" + "next-terminal/server/utils" +) + +var DatabaseSQLLogService = new(databaseSQLLogService) + +type databaseSQLLogService struct { + baseService +} + +type DatabaseSQLLogParams struct { + AssetId string + Database string + UserId string + ClientIP string + SQL string + DurationMs int + RowsAffected int + Status string + ErrorMessage string + Source string +} + +func (s databaseSQLLogService) Record(ctx context.Context, params DatabaseSQLLogParams) error { + log := &model.DatabaseSQLLog{ + ID: utils.UUID(), + AssetId: params.AssetId, + Database: params.Database, + UserId: params.UserId, + ClientIP: params.ClientIP, + SQL: params.SQL, + DurationMs: params.DurationMs, + RowsAffected: params.RowsAffected, + Status: params.Status, + ErrorMessage: params.ErrorMessage, + Source: params.Source, + Created: common.NowJsonTime(), + } + return repository.DatabaseSQLLogRepository.Create(ctx, log) +} diff --git a/server/service/operation_log.go b/server/service/operation_log.go new file mode 100644 index 000000000..f2c436dc0 --- /dev/null +++ b/server/service/operation_log.go @@ -0,0 +1,47 @@ +package service + +import ( + "context" + + "next-terminal/server/common" + "next-terminal/server/model" + "next-terminal/server/repository" + "next-terminal/server/utils" +) + +var OperationLogService = new(operationLogService) + +type operationLogService struct { + baseService +} + +type OperationLogParams struct { + AccountId string + AccountName string + Action string + Content string + IP string + Region string + UserAgent string + Status string + ErrorMessage string + Remark string +} + +func (s operationLogService) Record(ctx context.Context, params OperationLogParams) error { + log := &model.OperationLog{ + ID: utils.UUID(), + AccountId: params.AccountId, + AccountName: params.AccountName, + Action: params.Action, + Content: params.Content, + IP: params.IP, + Region: params.Region, + UserAgent: params.UserAgent, + Status: params.Status, + ErrorMessage: params.ErrorMessage, + Remark: params.Remark, + Created: common.NowJsonTime(), + } + return repository.OperationLogRepository.Create(ctx, log) +} diff --git a/server/service/property.go b/server/service/property.go index 94aad134c..449eacd61 100644 --- a/server/service/property.go +++ b/server/service/property.go @@ -4,12 +4,15 @@ import ( "context" "errors" "fmt" + "net" + "strings" "next-terminal/server/common/guacamole" "next-terminal/server/env" "next-terminal/server/model" "next-terminal/server/repository" + "github.com/labstack/echo/v4" "gorm.io/gorm" ) @@ -40,10 +43,19 @@ var defaultProperties = map[string]string{ guacamole.EnableMenuAnimations: "true", guacamole.DisableBitmapCaching: "false", guacamole.DisableOffscreenCaching: "false", + guacamole.ColorDepth: "", + guacamole.Cursor: "", + guacamole.SwapRedBlue: "false", "cron-log-saved-limit": "360", "login-log-saved-limit": "360", "session-saved-limit": "360", + "access-log-saved-limit": "30", + "filesystem-log-saved-limit": "30", + "operation-log-saved-limit": "30", + "database-sql-log-saved-limit": "30", "user-default-storage-size": "5120", + "ip-extractor": "direct", + "ip-trust-list": "", } func (service propertyService) InitProperties() error { @@ -60,6 +72,9 @@ func (service propertyService) InitProperties() error { func (service propertyService) CreateIfAbsent(propertyMap map[string]string, name, value string) error { if len(propertyMap[name]) == 0 { + if value == "" { + value = "-" + } property := model.Property{ Name: name, Value: value, @@ -117,3 +132,76 @@ func (service propertyService) Update(item map[string]interface{}) error { }) } + +func (service propertyService) GetClientIP(c echo.Context) string { + propertyMap := repository.PropertyRepository.FindAllMap(context.TODO()) + extractor := propertyMap["ip-extractor"] + trustList := propertyMap["ip-trust-list"] + + directIP := c.RealIP() + + if extractor == "" || extractor == "direct" { + return directIP + } + + if !service.isTrustedIP(directIP, trustList) { + return directIP + } + + switch extractor { + case "x-real-ip": + xRealIP := c.Request().Header.Get("X-Real-IP") + if xRealIP != "" { + return xRealIP + } + case "x-forwarded-for": + xForwardedFor := c.Request().Header.Get("X-Forwarded-For") + if xForwardedFor != "" { + ips := strings.Split(xForwardedFor, ",") + if len(ips) > 0 { + ip := strings.TrimSpace(ips[0]) + if ip != "" { + return ip + } + } + } + } + + return directIP +} + +func (service propertyService) isTrustedIP(clientIP string, trustList string) bool { + if trustList == "" { + return false + } + + trustIPs := strings.Split(trustList, ",") + clientIPAddr := net.ParseIP(clientIP) + if clientIPAddr == nil { + return false + } + + for _, trustIP := range trustIPs { + trustIP = strings.TrimSpace(trustIP) + if trustIP == "" { + continue + } + + if strings.Contains(trustIP, "/") { + _, ipNet, err := net.ParseCIDR(trustIP) + if err == nil && ipNet.Contains(clientIPAddr) { + return true + } + } else { + if trustIP == clientIP { + return true + } + trustIPAddr := net.ParseIP(trustIP) + if trustIPAddr != nil && trustIPAddr.Equal(clientIPAddr) { + return true + } + } + } + + return false +} diff --git a/server/task/ticker.go b/server/task/ticker.go index e3dc8ff78..383af87fd 100644 --- a/server/task/ticker.go +++ b/server/task/ticker.go @@ -43,6 +43,10 @@ func (t *Ticker) SetupTicker() { deleteOutTimeSession() deleteOutTimeLoginLog() deleteOutTimeJobLog() + deleteOutTimeAccessLog() + deleteOutTimeFilesystemLog() + deleteOutTimeOperationLog() + deleteOutTimeDatabaseSQLLog() } }() @@ -297,3 +301,115 @@ func deleteOutTimeJobLog() { } } } + +func deleteOutTimeAccessLog() { + property, err := repository.PropertyRepository.FindByName(context.TODO(), "access-log-saved-limit") + if err != nil { + return + } + if property.Value == "" || property.Value == "-" { + return + } + limit, err := strconv.Atoi(property.Value) + if err != nil { + return + } + + accessLogs, err := repository.AccessLogRepository.FindOutTimeLog(context.TODO(), limit) + if err != nil { + return + } + + if len(accessLogs) > 0 { + for i := range accessLogs { + err := repository.AccessLogRepository.DeleteById(context.TODO(), accessLogs[i].ID) + if err != nil { + log.Error("删除访问日志失败", log.NamedError("err", err)) + } + } + } +} + +func deleteOutTimeFilesystemLog() { + property, err := repository.PropertyRepository.FindByName(context.TODO(), "filesystem-log-saved-limit") + if err != nil { + return + } + if property.Value == "" || property.Value == "-" { + return + } + limit, err := strconv.Atoi(property.Value) + if err != nil { + return + } + + logs, err := repository.FilesystemLogRepository.FindOutTimeLog(context.TODO(), limit) + if err != nil { + return + } + + if len(logs) > 0 { + for i := range logs { + err := repository.FilesystemLogRepository.DeleteById(context.TODO(), logs[i].ID) + if err != nil { + log.Error("删除文件系统日志失败", log.NamedError("err", err)) + } + } + } +} + +func deleteOutTimeOperationLog() { + property, err := repository.PropertyRepository.FindByName(context.TODO(), "operation-log-saved-limit") + if err != nil { + return + } + if property.Value == "" || property.Value == "-" { + return + } + limit, err := strconv.Atoi(property.Value) + if err != nil { + return + } + + logs, err := repository.OperationLogRepository.FindOutTimeLog(context.TODO(), limit) + if err != nil { + return + } + + if len(logs) > 0 { + for i := range logs { + err := repository.OperationLogRepository.DeleteById(context.TODO(), logs[i].ID) + if err != nil { + log.Error("删除操作日志失败", log.NamedError("err", err)) + } + } + } +} + +func deleteOutTimeDatabaseSQLLog() { + property, err := repository.PropertyRepository.FindByName(context.TODO(), "database-sql-log-saved-limit") + if err != nil { + return + } + if property.Value == "" || property.Value == "-" { + return + } + limit, err := strconv.Atoi(property.Value) + if err != nil { + return + } + + logs, err := repository.DatabaseSQLLogRepository.FindOutTimeLog(context.TODO(), limit) + if err != nil { + return + } + + if len(logs) > 0 { + for i := range logs { + err := repository.DatabaseSQLLogRepository.DeleteById(context.TODO(), logs[i].ID) + if err != nil { + log.Error("删除数据库SQL日志失败", log.NamedError("err", err)) + } + } + } +} diff --git a/web/src/pages/access/AccessPage.tsx b/web/src/pages/access/AccessPage.tsx index bab1db5c6..d10457f8f 100644 --- a/web/src/pages/access/AccessPage.tsx +++ b/web/src/pages/access/AccessPage.tsx @@ -160,10 +160,15 @@ const AccessPage = () => { // 处理树节点双击 const handleNodeDoubleClick = useCallback((node: any) => { + let assetId = node.key as string; + if (typeof assetId === 'string' && assetId.startsWith('asset_')) { + assetId = assetId.substring(6); + } + // 检查是否需要 WOL 唤醒 if (node.extra?.status === 'inactive' && node.extra?.wolEnabled) { setWolAssetInfo({ - id: node.key as string, + id: assetId, name: node.title as string, protocol: node.extra?.protocol, }); @@ -173,7 +178,7 @@ const AccessPage = () => { // 直接打开连接 openAssetTab({ - id: node.key, + id: assetId, name: node.title, protocol: node.extra?.protocol, }); diff --git a/web/src/pages/access/AccessSshChooser.tsx b/web/src/pages/access/AccessSshChooser.tsx index ca27f8826..d203c7346 100644 --- a/web/src/pages/access/AccessSshChooser.tsx +++ b/web/src/pages/access/AccessSshChooser.tsx @@ -61,7 +61,13 @@ const AccessSshChooser = ({handleOk, handleCancel, open}: Props) => { const onCheck: TreeProps['onCheck'] = (checkedKeysValue, {checkedNodes}) => { // console.log('onCheck', checkedKeysValue, checkedNodes); - let keys = checkedNodes.filter(item => item.isLeaf).map((item) => item.key); + let keys = checkedNodes.filter(item => item.isLeaf).map((item) => { + let key = item.key as string; + if (key.startsWith('asset_')) { + return key.substring(6); + } + return key; + }); setSshAssetKeys(keys as string[]); }; diff --git a/web/src/pages/access/FileSystemPage.tsx b/web/src/pages/access/FileSystemPage.tsx index d70b85256..347876ed2 100644 --- a/web/src/pages/access/FileSystemPage.tsx +++ b/web/src/pages/access/FileSystemPage.tsx @@ -762,7 +762,6 @@ const FileSystemPage = forwardRef(({ console.log(`Upload response - Status: ${xhr.status}, Response: ${xhr.responseText}`); if (xhr.status >= 200 && xhr.status < 300) { - // 检查响应体是否包含错误信息 let hasError = false; let errorMessage = ''; @@ -772,15 +771,13 @@ const FileSystemPage = forwardRef(({ const result = JSON.parse(responseText); console.log('Upload response parsed:', result); - // 检查是否是标准错误响应格式 - if (result.error === true || (result.message && result.code)) { + if (result.error === true || (result.code && result.code !== 1 && result.code !== 0)) { hasError = true; errorMessage = result.message || 'Upload failed'; console.error('Upload failed with parsed error:', errorMessage); } } } catch (e) { - // JSON解析失败,可能是成功的空响应,继续处理为成功 console.log('Upload response parse failed, treating as success:', e); } @@ -1126,7 +1123,7 @@ const FileSystemPage = forwardRef(({ return (
- (({ )} { let [groupId, setGroupId] = useState(searchParams.get('groupId') || ''); let [dataSource, setDataSource] = useState([]); + const handleTreeSelect = (key: string) => { + if (key.startsWith('group_')) { + setGroupId(key.substring(6)); + } else if (key.startsWith('asset_')) { + setGroupId(''); + } else { + setGroupId(key); + } + }; + let [selectedTags, setSelectedTags] = useState([]); let [groupChooserOpen, setGroupChooserOpen] = useState(false); let [gatewayChooserOpen, setGatewayChooserOpen] = useState(false); @@ -608,7 +618,7 @@ const AssetPage = () => { /* 移动端:垂直布局,树在上,标签过�?+ 表格在下 */ <>
- +
{tagFilter} @@ -621,7 +631,7 @@ const AssetPage = () => { )}>
{!isTreeCollapsed && ( - + )}
{ let [op, setOP] = useState(); let [expandedKeys, setExpandedKeys] = useState([]); - let [selectedKeys, setSelectedKeys] = useState([selected]); + let [selectedKeys, setSelectedKeys] = useState([]); const [theme] = useNTTheme(); let query = useQuery({ @@ -38,6 +38,14 @@ const AssetTree = ({selected, onSelect}: Props) => { queryFn: assetApi.getGroups, }); + useEffect(() => { + if (selected) { + setSelectedKeys(['group_' + selected]); + } else { + setSelectedKeys([]); + } + }, [selected]); + useEffect(() => { if (Array.isArray(query.data) && query.data.length > 0) { setTreeData(query.data); @@ -197,7 +205,11 @@ const AssetTree = ({selected, onSelect}: Props) => { danger: true, icon: , onClick: () => { - assetApi.deleteGroup(contextMenu.node.key as string).then(() => { + let groupKey = contextMenu.node.key as string; + if (groupKey.startsWith('group_')) { + groupKey = groupKey.substring(6); + } + assetApi.deleteGroup(groupKey).then(() => { query.refetch(); }) }, @@ -205,7 +217,7 @@ const AssetTree = ({selected, onSelect}: Props) => { ]; const handleRightClick = ({event, node}) => { - if (node.key === 'default') { + if (node.key === 'default' || node.key?.toString().startsWith('asset_')) { return; } // console.log(`handleRightClick`, event, node) diff --git a/web/src/pages/sysconf/DbProxySetting.tsx b/web/src/pages/sysconf/DbProxySetting.tsx index 13cfc710e..ef24b8d2d 100644 --- a/web/src/pages/sysconf/DbProxySetting.tsx +++ b/web/src/pages/sysconf/DbProxySetting.tsx @@ -58,7 +58,6 @@ const DbProxySetting = ({get, set}: SettingProps) => { { style: {display: 'none'} } }}> - - { }} addonAfter={t('general.days')} /> - { }} addonAfter={t('general.days')} /> - { }} addonAfter={t('general.days')} /> - { const { isMobile } = useMobile(); let {t} = useTranslation(); - const [form] = Form.useForm(); let [enabled, setEnabled] = useState(false); let {message} = App.useApp(); - let query = useQuery({ - queryKey: ['get-property'], - queryFn: get, - }); - - useEffect(() => { - if (query.data) { - form.setFieldsValue(query.data); - setEnabled(query.data['mail-enabled']); - } - }, [query.data]); + const wrapGet = async () => { + let values = await get(); + setEnabled(values['mail-enabled']); + return values; + } const handleSendTestMail = async (values: any) => { await propertyApi.sendMail(values); @@ -43,14 +34,13 @@ const MailSetting = ({get, set}: SettingProps) => {
- { }}> diff --git a/web/src/pages/sysconf/SecuritySetting.tsx b/web/src/pages/sysconf/SecuritySetting.tsx index e98007b21..4fced405c 100644 --- a/web/src/pages/sysconf/SecuritySetting.tsx +++ b/web/src/pages/sysconf/SecuritySetting.tsx @@ -57,14 +57,12 @@ const SecuritySetting = ({get, set}: SettingProps) => { @@ -72,7 +70,6 @@ const SecuritySetting = ({get, set}: SettingProps) => { @@ -82,7 +79,6 @@ const SecuritySetting = ({get, set}: SettingProps) => { @@ -102,7 +98,6 @@ const SecuritySetting = ({get, set}: SettingProps) => { { { }}> { let {t} = useTranslation(); - const [form] = Form.useForm(); - - let query = useQuery({ - queryKey: ['get-property'], - queryFn: get, - }); - - useEffect(() => { - if (query.data) { - form.setFieldsValue(query.data); - } - }, [query.data]); return (
@@ -55,7 +42,6 @@ const VncSetting = ({get, set}: SettingProps) => { /> diff --git a/web/src/react-i18next/locales/en-US.json b/web/src/react-i18next/locales/en-US.json index cb5b5e681..b624c2838 100644 --- a/web/src/react-i18next/locales/en-US.json +++ b/web/src/react-i18next/locales/en-US.json @@ -198,6 +198,7 @@ } }, "fs": { + "title": "File Manager", "operations": { "batch_download": "Batch Download", "create_dir": "Create Folder", diff --git a/web/src/react-i18next/locales/zh-CN.json b/web/src/react-i18next/locales/zh-CN.json index ff39fc556..2db5d7a8c 100644 --- a/web/src/react-i18next/locales/zh-CN.json +++ b/web/src/react-i18next/locales/zh-CN.json @@ -440,6 +440,7 @@ } }, "fs": { + "title": "文件管理", "operations": { "batch_download": "批量下载", "create_dir": "创建文件夹",