diff --git a/backend/cmd/seed.go b/backend/cmd/seed.go index 7899f15..52390de 100644 --- a/backend/cmd/seed.go +++ b/backend/cmd/seed.go @@ -1,6 +1,8 @@ package main import ( + "strings" + "ezops/internal/models" "go.uber.org/zap" @@ -8,7 +10,7 @@ import ( "gorm.io/gorm" ) -// seedData 初始化种子数据(admin用户 + LDAP配置) +// seedData 初始化种子数据(admin用户 + LDAP配置 + 审批模板 + 权限角色) func seedData(db *gorm.DB) { // 1. 创建默认 admin 用户(仅首次) var userCount int64 @@ -110,4 +112,112 @@ func seedData(db *gorm.DB) { zap.L().Info("seeded default approval templates (server_apply + app_deploy)") } } + + // 4. 创建默认权限和角色权限映射 + var permCount int64 + db.Model(&models.Permission{}).Count(&permCount) + if permCount == 0 { + perms := []models.Permission{ + // 资产管理 + {Name: "服务器管理", Code: "server:view", Type: "menu", Sort: 1, Status: 1}, + {Name: "服务器创建", Code: "server:create", Type: "button", Sort: 2, Status: 1}, + {Name: "服务器编辑", Code: "server:update", Type: "button", Sort: 3, Status: 1}, + {Name: "服务器删除", Code: "server:delete", Type: "button", Sort: 4, Status: 1}, + {Name: "WebShell终端", Code: "server:webshell", Type: "button", Sort: 5, Status: 1}, + // 云资源 + {Name: "云账号管理", Code: "cloud:account", Type: "menu", Sort: 10, Status: 1}, + {Name: "域名管理", Code: "cloud:domain", Type: "menu", Sort: 11, Status: 1}, + {Name: "云账单", Code: "cloud:billing", Type: "menu", Sort: 12, Status: 1}, + // Kubernetes + {Name: "集群管理", Code: "k8s:cluster", Type: "menu", Sort: 20, Status: 1}, + {Name: "节点管理", Code: "k8s:nodes", Type: "menu", Sort: 21, Status: 1}, + {Name: "工作负载", Code: "k8s:workloads", Type: "menu", Sort: 22, Status: 1}, + {Name: "架构拓扑", Code: "k8s:topology", Type: "menu", Sort: 23, Status: 1}, + // 应用发布 + {Name: "流水线", Code: "deploy:pipeline", Type: "menu", Sort: 30, Status: 1}, + {Name: "应用发布", Code: "deploy:release", Type: "menu", Sort: 31, Status: 1}, + // 监控告警 + {Name: "告警中心", Code: "monitor:alert", Type: "menu", Sort: 40, Status: 1}, + {Name: "告警规则", Code: "monitor:rule", Type: "button", Sort: 41, Status: 1}, + {Name: "Agent监控", Code: "agent:monitor", Type: "menu", Sort: 42, Status: 1}, + {Name: "Agent告警", Code: "agent:alert", Type: "menu", Sort: 43, Status: 1}, + // 自动化运维 + {Name: "定时任务", Code: "auto:cron", Type: "menu", Sort: 50, Status: 1}, + {Name: "脚本管理", Code: "auto:script", Type: "menu", Sort: 51, Status: 1}, + {Name: "Ansible", Code: "auto:ansible", Type: "menu", Sort: 52, Status: 1}, + // AIOps + {Name: "智能助手", Code: "aiops:assistant", Type: "menu", Sort: 60, Status: 1}, + {Name: "告警分析", Code: "aiops:analysis", Type: "menu", Sort: 61, Status: 1}, + {Name: "容量预测", Code: "aiops:forecast", Type: "menu", Sort: 62, Status: 1}, + // 资源申请 + {Name: "资源申请", Code: "approval:apply", Type: "menu", Sort: 70, Status: 1}, + {Name: "审批管理", Code: "approval:manage", Type: "button", Sort: 71, Status: 1}, + // 系统管理 + {Name: "用户管理", Code: "system:user", Type: "menu", Sort: 80, Status: 1}, + {Name: "角色权限", Code: "system:role", Type: "menu", Sort: 81, Status: 1}, + {Name: "操作审计", Code: "system:audit", Type: "menu", Sort: 82, Status: 1}, + {Name: "LDAP配置", Code: "system:ldap", Type: "menu", Sort: 83, Status: 1}, + // 仪表盘 + {Name: "仪表盘", Code: "dashboard:view", Type: "menu", Sort: 90, Status: 1}, + } + db.Create(&perms) + zap.L().Info("seeded default permissions") + } + + // 5. 创建默认角色(如果不存在) + var roleCount int64 + db.Model(&models.Role{}).Count(&roleCount) + if roleCount == 0 { + roles := []models.Role{ + {Name: "管理员", Code: "admin", Description: "系统管理员,拥有全部权限", Status: 1}, + {Name: "运维工程师", Code: "ops", Description: "日常运维管理", Status: 1}, + {Name: "开发工程师", Code: "developer", Description: "开发人员,只读权限", Status: 1}, + {Name: "只读用户", Code: "viewer", Description: "仅查看权限", Status: 1}, + } + db.Create(&roles) + zap.L().Info("seeded default roles") + + // 为各角色分配权限 + var allPerms []models.Permission + db.Find(&allPerms) + for _, role := range roles { + var permIds []uint + switch role.Code { + case "admin": + for _, p := range allPerms { + permIds = append(permIds, p.ID) + } + case "ops": + for _, p := range allPerms { + if !strings.HasPrefix(p.Code, "system:") { + permIds = append(permIds, p.ID) + } + } + case "developer": + devPerms := map[string]bool{ + "dashboard:view": true, "server:view": true, "cloud:account": true, + "cloud:domain": true, "cloud:billing": true, + "k8s:cluster": true, "k8s:nodes": true, "k8s:workloads": true, "k8s:topology": true, + "monitor:alert": true, "deploy:pipeline": true, "deploy:release": true, + "approval:apply": true, "aiops:assistant": true, "aiops:analysis": true, "aiops:forecast": true, + } + for _, p := range allPerms { + if devPerms[p.Code] { + permIds = append(permIds, p.ID) + } + } + case "viewer": + for _, p := range allPerms { + if p.Code == "dashboard:view" || p.Code == "server:view" || p.Code == "monitor:alert" { + permIds = append(permIds, p.ID) + } + } + } + if len(permIds) > 0 { + var rolePerms []models.Permission + db.Where("id IN ?", permIds).Find(&rolePerms) + db.Model(&role).Association("Permissions").Append(rolePerms) + } + } + } } diff --git a/backend/internal/api/handlers/server.go b/backend/internal/api/handlers/server.go index 9566b81..04291b8 100644 --- a/backend/internal/api/handlers/server.go +++ b/backend/internal/api/handlers/server.go @@ -20,6 +20,27 @@ func NewServerHandler(db *gorm.DB) *ServerHandler { return &ServerHandler{db: db} } +// recordServerAudit 记录服务器操作审计日志 // Logan.Li +func (h *ServerHandler) recordServerAudit(c *gin.Context, action, resource string, resourceID string, status int, errMsg string) { + userID, _ := c.Get("userID") + username, _ := c.Get("username") + uid, _ := userID.(uint) + uname, _ := username.(string) + + h.db.Create(&models.AuditLog{ + UserID: &uid, + Username: uname, + IP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + Module: "server", + Action: action, + Resource: resource, + ResourceID: resourceID, + Status: status, + Error: errMsg, + }) +} + // ListServers 获取服务器列表 // @Summary 获取服务器列表 // @Description 获取所有服务器列表 @@ -170,6 +191,8 @@ func (h *ServerHandler) CreateServer(c *gin.Context) { return } + h.recordServerAudit(c, "create", server.Hostname+" ("+server.IP+")", strconv.Itoa(int(server.ID)), 1, "") + c.JSON(http.StatusOK, models.Response{ Code: 200, Message: "创建成功", @@ -268,6 +291,7 @@ func (h *ServerHandler) UpdateServer(c *gin.Context) { } h.db.Preload("Region").Preload("Cloud").First(&server, id) + h.recordServerAudit(c, "update", server.Hostname+" ("+server.IP+")", strconv.Itoa(int(server.ID)), 1, "") c.JSON(http.StatusOK, models.Response{ Code: 200, @@ -288,6 +312,15 @@ func (h *ServerHandler) UpdateServer(c *gin.Context) { func (h *ServerHandler) DeleteServer(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) + var server models.Server + if err := h.db.First(&server, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "服务器不存在", + }) + return + } + if err := h.db.Delete(&models.Server{}, id).Error; err != nil { c.JSON(http.StatusInternalServerError, models.Response{ Code: 500, @@ -296,6 +329,8 @@ func (h *ServerHandler) DeleteServer(c *gin.Context) { return } + h.recordServerAudit(c, "delete", server.Hostname+" ("+server.IP+")", strconv.Itoa(int(server.ID)), 1, "") + c.JSON(http.StatusOK, models.Response{ Code: 200, Message: "删除成功", diff --git a/backend/internal/api/handlers/server_batch.go b/backend/internal/api/handlers/server_batch.go index 081a812..1b02c7b 100644 --- a/backend/internal/api/handlers/server_batch.go +++ b/backend/internal/api/handlers/server_batch.go @@ -3,6 +3,7 @@ package handlers import ( "fmt" "net/http" + "strconv" "strings" "ezops/internal/models" @@ -106,6 +107,10 @@ func (h *ServerHandler) BatchDeleteServers(c *gin.Context) { return } + // 查询待删除服务器用于审计日志 // Logan.Li + var servers []models.Server + h.db.Where("id IN ?", req.IDs).Find(&servers) + if err := h.db.Delete(&models.Server{}, req.IDs).Error; err != nil { c.JSON(http.StatusInternalServerError, models.Response{ Code: 500, @@ -114,6 +119,11 @@ func (h *ServerHandler) BatchDeleteServers(c *gin.Context) { return } + // 记录批量删除审计日志 + for _, s := range servers { + h.recordServerAudit(c, "delete", s.Hostname+" ("+s.IP+")", strconv.Itoa(int(s.ID)), 1, "") + } + c.JSON(http.StatusOK, models.Response{ Code: 200, Message: "删除成功", diff --git a/backend/internal/api/handlers/terminal_session.go b/backend/internal/api/handlers/terminal_session.go new file mode 100644 index 0000000..3564c1c --- /dev/null +++ b/backend/internal/api/handlers/terminal_session.go @@ -0,0 +1,147 @@ +package handlers + +import ( + "net/http" + "strconv" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// TerminalSessionHandler 终端会话审计处理器 // Logan.Li +type TerminalSessionHandler struct { + db *gorm.DB +} + +// NewTerminalSessionHandler 创建终端会话审计处理器 +func NewTerminalSessionHandler(db *gorm.DB) *TerminalSessionHandler { + return &TerminalSessionHandler{db: db} +} + +// ListTerminalSessions 终端会话列表 +// GET /api/v1/terminal-sessions?server_id=&username=&status=&page=&page_size= +func (h *TerminalSessionHandler) ListTerminalSessions(c *gin.Context) { + var req models.TerminalSessionQuery + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 20 + } + + query := h.db.Model(&models.TerminalSession{}) + if req.ServerID > 0 { + query = query.Where("server_id = ?", req.ServerID) + } + if req.Username != "" { + query = query.Where("username LIKE ?", "%"+req.Username+"%") + } + if req.Status != "" { + query = query.Where("status = ?", req.Status) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败"}) + return + } + + var sessions []models.TerminalSession + if err := query.Order("id DESC"). + Offset((req.Page - 1) * req.PageSize). + Limit(req.PageSize). + Find(&sessions).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败"}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{ + List: sessions, + Total: total, + }}) +} + +// GetTerminalSession 终端会话详情 +// GET /api/v1/terminal-sessions/:id +func (h *TerminalSessionHandler) GetTerminalSession(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的会话ID"}) + return + } + + var session models.TerminalSession + if err := h.db.First(&session, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "会话不存在"}) + return + } + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败"}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: session}) +} + +// GetTerminalRecord 终端会话录制数据(用于回放) +// GET /api/v1/terminal-sessions/:id/record +func (h *TerminalSessionHandler) GetTerminalRecord(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的会话ID"}) + return + } + + var session models.TerminalSession + if err := h.db.First(&session, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "会话不存在"}) + return + } + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败"}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: session.RecordData}) +} + +// DeleteTerminalSession 删除终端会话记录 +// DELETE /api/v1/terminal-sessions/:id +func (h *TerminalSessionHandler) DeleteTerminalSession(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的会话ID"}) + return + } + + if err := h.db.Delete(&models.TerminalSession{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) +} + +// BatchDeleteTerminalSessions 批量删除终端会话记录 +// POST /api/v1/terminal-sessions/batch-delete +func (h *TerminalSessionHandler) BatchDeleteTerminalSessions(c *gin.Context) { + var req models.IDsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的请求参数"}) + return + } + + result := h.db.Where("id IN ?", req.IDs).Delete(&models.TerminalSession{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功", Data: gin.H{"deleted": result.RowsAffected}}) +} diff --git a/backend/internal/api/handlers/webshell.go b/backend/internal/api/handlers/webshell.go index 2e4ccf1..8295317 100644 --- a/backend/internal/api/handlers/webshell.go +++ b/backend/internal/api/handlers/webshell.go @@ -5,6 +5,7 @@ import ( "log" "net/http" "strconv" + "strings" "sync" "time" @@ -43,7 +44,44 @@ func NewWebShellHandler(db *gorm.DB) *WebShellHandler { return &WebShellHandler{db: db} } -// Terminal WebSocket终端连接 — 真实SSH // Logan.Li +// formatSSHError 将 Go SSH 错误转换为友好的中文提示 // Logan.Li +func formatSSHError(err error) string { + errStr := err.Error() + switch { + case contains(errStr, "i/o timeout"): + return "连接超时(目标主机可能在防火墙后或网络不通)" + case contains(errStr, "connection refused"): + return "连接被拒绝(SSH 服务未运行或端口错误)" + case contains(errStr, "no route to host"): + return "无法路由到目标主机(IP 地址不可达)" + case contains(errStr, "authentication failed") || contains(errStr, "unable to authenticate"): + return "认证失败(用户名或密码错误)" + case contains(errStr, "handshake"): + return "SSH 握手失败(目标可能不是 SSH 服务)" + case contains(errStr, "dns") || contains(errStr, "no such host"): + return "域名解析失败(IP 地址无效)" + case contains(errStr, "ssh: must specify"): + return "认证方式错误(未配置密码或密钥)" + default: + return errStr + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && indexOf(s, substr) >= 0)) +} + +func indexOf(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} + +// Terminal WebSocket终端连接 — 真实SSH,带终端会话录制 // Logan.Li func (h *WebShellHandler) Terminal(c *gin.Context) { serverID := c.Query("id") if serverID == "" { @@ -65,9 +103,51 @@ func (h *WebShellHandler) Terminal(c *gin.Context) { } defer conn.Close() + // 创建终端会话记录(终端审计) // Logan.Li + var userIDPtr *uint + if uid, exists := c.Get("userID"); exists { + if uidVal, ok := uid.(uint); ok { + userIDPtr = &uidVal + } + } + username, _ := c.Get("username") + usernameStr, _ := username.(string) + + termSession := models.TerminalSession{ + UserID: userIDPtr, + Username: usernameStr, + ServerID: server.ID, + ServerName: server.Hostname, + ServerIP: server.IP, + ServerPort: server.Port, + ClientIP: c.ClientIP(), + Status: "active", + StartTime: time.Now(), + } + if err := h.db.Create(&termSession).Error; err != nil { + log.Printf("[WebShell] 创建会话记录失败: %v", err) + } + + // 终端录制缓冲(限制 1MB 避免内存问题) // Logan.Li + const maxRecordSize = 1024 * 1024 + var recordBuf strings.Builder + + // finalizeSession 结束会话记录(统一收尾) + finalizeSession := func(status string) { + endTime := time.Now() + duration := int(endTime.Sub(termSession.StartTime).Seconds()) + h.db.Model(&termSession).Updates(map[string]interface{}{ + "status": status, + "duration": duration, + "end_time": &endTime, + "record_data": recordBuf.String(), + "record_size": recordBuf.Len(), + }) + } + conn.WriteJSON(WSMessage{ Status: 0, - Data: "\r\n\x1b[36m正在连接 " + server.Hostname + " (" + server.IP + ")...\x1b[0m\r\n", + Data: "\r\n\x1b[36m正在连接 " + server.Hostname + " (" + server.IP + ":" + strconv.Itoa(server.Port) + ")...\x1b[0m\r\n", }) // 构建 SSH 配置 @@ -96,10 +176,16 @@ func (h *WebShellHandler) Terminal(c *gin.Context) { sshClient, err := ssh.Dial("tcp", addr, sshConfig) if err != nil { log.Printf("[WebShell] SSH连接失败 %s: %v", addr, err) - conn.WriteJSON(WSMessage{ - Status: 0, - Data: "\r\n\x1b[31mSSH连接失败: " + err.Error() + "\x1b[0m\r\n\x1b[33m提示: 请检查服务器IP/端口/密码是否正确,目标主机是否在线\x1b[0m\r\n", - }) + friendlyErr := formatSSHError(err) + errMsg := "\r\n\x1b[31m连接失败: " + friendlyErr + "\x1b[0m\r\n" + + "\x1b[33m──────────────────────────\x1b[0m\r\n" + + "\x1b[33m目标:\x1b[0m " + server.Hostname + " (" + server.IP + ":" + strconv.Itoa(server.Port) + ")\r\n" + + "\x1b[33m账号:\x1b[0m " + server.Username + "\r\n" + + "\x1b[33m排查:\x1b[0m ① 检查 IP/端口是否正确 ② 确认 SSH 服务运行中 ③ 验证账号密码\r\n" + + "\x1b[33m──────────────────────────\x1b[0m\r\n" + conn.WriteJSON(WSMessage{Status: 0, Data: errMsg}) + recordBuf.WriteString(errMsg) + finalizeSession("failed") conn.Close() return } @@ -112,6 +198,7 @@ func (h *WebShellHandler) Terminal(c *gin.Context) { if err != nil { log.Printf("[WebShell] 创建session失败: %v", err) conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31m创建SSH会话失败\x1b[0m\r\n"}) + finalizeSession("failed") return } defer session.Close() @@ -125,45 +212,62 @@ func (h *WebShellHandler) Terminal(c *gin.Context) { if err := session.RequestPty("xterm-256color", 24, 80, modes); err != nil { log.Printf("[WebShell] PTY请求失败: %v", err) conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mPTY请求失败\x1b[0m\r\n"}) + finalizeSession("failed") return } // 获取管道 stdinPipe, err := session.StdinPipe() if err != nil { + finalizeSession("failed") return } stdoutPipe, err := session.StdoutPipe() if err != nil { + finalizeSession("failed") return } // 启动 shell if err := session.Shell(); err != nil { log.Printf("[WebShell] Shell启动失败: %v", err) + finalizeSession("failed") return } // 发送欢迎消息 + welcome := "\r\n\x1b[32mEZOps WebShell\x1b[0m — 已连接 " + server.Hostname + " (" + server.IP + ")\r\n\r\n" conn.WriteJSON(WSMessage{ Status: 0, - Data: "\r\n\x1b[32mEZOps WebShell\x1b[0m — 已连接 " + server.Hostname + " (" + server.IP + ")\r\n\r\n", + Data: welcome, }) + recordBuf.WriteString(welcome) done := make(chan struct{}) - // goroutine: SSH stdout -> WebSocket + // goroutine: SSH stdout -> WebSocket(同时录制输出) // Logan.Li go func() { defer close(done) buf := make([]byte, 8192) for { n, err := stdoutPipe.Read(buf) if err != nil { - conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31m[远程连接已断开]\x1b[0m\r\n"}) + discMsg := "\r\n\x1b[31m[远程连接已断开]\x1b[0m\r\n" + conn.WriteJSON(WSMessage{Status: 0, Data: discMsg}) + recordBuf.WriteString(discMsg) break } if n > 0 { conn.WriteJSON(WSMessage{Status: 0, Data: string(buf[:n])}) + // 录制终端输出(受 1MB 上限保护) + if recordBuf.Len() < maxRecordSize { + remain := maxRecordSize - recordBuf.Len() + if n > remain { + recordBuf.Write(buf[:remain]) + } else { + recordBuf.Write(buf[:n]) + } + } } } }() @@ -195,5 +299,6 @@ func (h *WebShellHandler) Terminal(c *gin.Context) { // 等待 stdout goroutine 结束 <-done stdinPipe.Close() + finalizeSession("closed") log.Printf("[WebShell] 断开 %s (%s)", server.Hostname, addr) } diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index c7b09a2..251b611 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -39,9 +39,9 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { }) }) - // WebSocket终端 (token via query param) + // WebSocket终端 (JWT 鉴权,支持 ?token= 查询参数;终端会话录制) // Logan.Li webShellHandler := handlers.NewWebShellHandler(db) - r.GET("/ws/terminal", webShellHandler.Terminal) + r.GET("/ws/terminal", middleware.JWTAuth(&cfg.JWT), webShellHandler.Terminal) // 公开路由 authHandler := handlers.NewUserHandler(db, cfg) @@ -371,6 +371,17 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { audit.POST("/logs/clear", auditHandler.ClearAuditLogs) } + // 终端会话审计路由(WebSSH 录制回放) // Logan.Li + terminalSessionHandler := handlers.NewTerminalSessionHandler(db) + ts := authorized.Group("/terminal-sessions") + { + ts.GET("", terminalSessionHandler.ListTerminalSessions) + ts.POST("/batch-delete", terminalSessionHandler.BatchDeleteTerminalSessions) + ts.GET("/:id", terminalSessionHandler.GetTerminalSession) + ts.GET("/:id/record", terminalSessionHandler.GetTerminalRecord) + ts.DELETE("/:id", terminalSessionHandler.DeleteTerminalSession) + } + // Agent 上报路由(无需 JWT) agentHandler := handlers.NewAgentHandler(db) taskHandler := handlers.NewAgentTaskHandler(db) diff --git a/backend/internal/models/terminal_session.go b/backend/internal/models/terminal_session.go new file mode 100644 index 0000000..ea0b9d2 --- /dev/null +++ b/backend/internal/models/terminal_session.go @@ -0,0 +1,36 @@ +package models + +import "time" + +// TerminalSession SSH终端会话记录(JumpServer 式终端审计) // Logan.Li +type TerminalSession struct { + ID uint `gorm:"primarykey" json:"id"` + UserID *uint `json:"user_id"` + Username string `gorm:"size:64" json:"username"` + ServerID uint `json:"server_id"` + ServerName string `gorm:"size:128" json:"server_name"` + ServerIP string `gorm:"size:64" json:"server_ip"` + ServerPort int `json:"server_port"` + ClientIP string `gorm:"size:64" json:"client_ip"` + Status string `gorm:"size:32" json:"status"` // active, closed, failed + Duration int `json:"duration"` // seconds + RecordData string `gorm:"type:text" json:"-"` // 终端录制原始输出(不直接返回 JSON) + RecordSize int `json:"record_size"` // 录制字节数 + StartTime time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` + CreatedAt time.Time `json:"created_at"` +} + +// TableName 表名 +func (TerminalSession) TableName() string { + return "terminal_sessions" +} + +// TerminalSessionQuery 终端会话查询 +type TerminalSessionQuery struct { + ServerID uint `form:"server_id"` + Username string `form:"username"` + Status string `form:"status"` + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=20"` +} diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index ce320b4..2ad86d7 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -100,6 +100,7 @@ func AutoMigrate(db *gorm.DB) error { &models.AnsibleInventory{}, &models.LogSource{}, &models.LogEntry{}, - &models.TopologyLayout{}, // 拓扑布局保存 // Logan.Li + &models.TopologyLayout{}, // 拓扑布局保存 // Logan.Li + &models.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li ) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index db3db97..f2e4099 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -88,6 +88,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index f25ea17..95b07d5 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -123,6 +123,7 @@ const menuItems: MenuItem[] = [ { id: 'users', name: '用户管理', icon: Users, path: '/users' }, { id: 'roles', name: '角色权限', icon: Shield, path: '/roles' }, { id: 'logs-audit', name: '操作审计', icon: FileCode, path: '/logs-audit' }, + { id: 'terminal-audit', name: '终端审计', icon: Terminal, path: '/terminal-audit' }, { id: 'settings', name: '系统设置', icon: Settings, path: '/settings' }, ], }, diff --git a/frontend/src/components/server/TerminalDialog.tsx b/frontend/src/components/server/TerminalDialog.tsx index 546aa33..8ec2091 100644 --- a/frontend/src/components/server/TerminalDialog.tsx +++ b/frontend/src/components/server/TerminalDialog.tsx @@ -10,6 +10,7 @@ import { FitAddon } from 'xterm-addon-fit'; import 'xterm/css/xterm.css'; import { Maximize2, Minimize2, X } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useUserStore } from '@/store'; interface TerminalDialogProps { open: boolean; @@ -61,7 +62,8 @@ export function TerminalDialog({ // WebSocket 连接 const proto = location.protocol === 'https:' ? 'wss' : 'ws'; - const wsUrl = `${proto}://${location.host}/ws/terminal?id=${serverId}&cols=80&rows=24`; + const token = useUserStore.getState().token || ''; + const wsUrl = `${proto}://${location.host}/ws/terminal?id=${serverId}&cols=80&rows=24&token=${encodeURIComponent(token)}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; diff --git a/frontend/src/components/server/WebShell.tsx b/frontend/src/components/server/WebShell.tsx index 847acc5..75a53af 100644 --- a/frontend/src/components/server/WebShell.tsx +++ b/frontend/src/components/server/WebShell.tsx @@ -30,6 +30,7 @@ import { Terminal as XTerm } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import 'xterm/css/xterm.css'; import { serverApi } from '@/services/api'; +import { useUserStore } from '@/store'; interface TerminalSession { id: string; @@ -116,7 +117,8 @@ export function WebShell() { term.loadAddon(fit); const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws'; - const wsUrl = `${proto}://${location.host}/ws/terminal?id=${server.id}&cols=${cols}&rows=${rows}`; + const token = useUserStore.getState().token || ''; + const wsUrl = `${proto}://${location.host}/ws/terminal?id=${server.id}&cols=${cols}&rows=${rows}&token=${encodeURIComponent(token)}`; const ws = new WebSocket(wsUrl); // 终端输入 -> WebSocket diff --git a/frontend/src/components/system/ReplayDialog.tsx b/frontend/src/components/system/ReplayDialog.tsx new file mode 100644 index 0000000..d885557 --- /dev/null +++ b/frontend/src/components/system/ReplayDialog.tsx @@ -0,0 +1,105 @@ +import { useState, useEffect, useRef } from 'react'; +import { Terminal as XTerm } from 'xterm'; +import { FitAddon } from 'xterm-addon-fit'; +import 'xterm/css/xterm.css'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Loader2 } from 'lucide-react'; +import { terminalSessionApi } from '@/services/api'; + +interface ReplayDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + sessionId: number | null; +} + +/** + * 终端录制回放 Dialog — 用 xterm.js 重放录制的终端输出 // Logan.Li + */ +export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProps) { + const containerRef = useRef(null); + const termRef = useRef(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open || !sessionId) return; + + const term = new XTerm({ + cursorBlink: false, + fontSize: 13, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + disableStdin: true, + allowProposedApi: true, + theme: { + background: '#0a0e14', + foreground: '#d6d6d6', + cursor: '#0a0e14', + selectionBackground: 'rgba(255,255,255,0.2)', + }, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + termRef.current = term; + + let cancelled = false; + setLoading(true); + terminalSessionApi + .getRecord(sessionId) + .then((data: any) => { + if (cancelled) return; + if (containerRef.current) { + term.open(containerRef.current); + try { + fit.fit(); + } catch { + /* noop */ + } + const output = typeof data === 'string' ? data : ''; + term.write(output); + } + }) + .catch(() => { + if (!cancelled && containerRef.current) { + term.open(containerRef.current); + term.write('\x1b[31m无录制数据\x1b[0m\r\n'); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + try { + term.dispose(); + } catch { + /* noop */ + } + termRef.current = null; + }; + }, [open, sessionId]); + + return ( + + + + 终端录制回放 #{sessionId ?? ''} + 重放该会话录制的终端输出 + +
+ {loading && ( +
+ +
+ )} +
+
+ +
+ ); +} diff --git a/frontend/src/components/system/SystemManager.tsx b/frontend/src/components/system/SystemManager.tsx index 59517db..de6dad3 100644 --- a/frontend/src/components/system/SystemManager.tsx +++ b/frontend/src/components/system/SystemManager.tsx @@ -1,14 +1,16 @@ import { useState, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; import { useConfirm } from '@/components/ui/confirm-dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; -import { CheckCircle, XCircle, Users, Shield, FileCode, Settings } from 'lucide-react'; +import { CheckCircle, XCircle, Users, Shield, FileCode, Settings, TerminalSquare } from 'lucide-react'; import { userApi } from '@/services/api'; import type { Role } from '@/types'; import { SystemUserTab } from './SystemUserTab'; import { SystemRolesTab } from './SystemRolesTab'; import { SystemLogsTab } from './SystemLogsTab'; import { SystemSettingsTab } from './SystemSettingsTab'; +import { TerminalSessionsTab } from './TerminalSessionsTab'; import { useLdapManagement } from './useLdapManagement'; import type { UserFormData } from './UserFormDialog'; @@ -36,7 +38,24 @@ const emptyForm: UserFormData = { export function SystemManager() { const confirm = useConfirm(); - const [activeTab, setActiveTab] = useState('users'); + const location = useLocation(); + // 侧边栏菜单路径映射到 Tab // Logan.Li + const pathToTab: Record = { + '/users': 'users', + '/roles': 'roles', + '/logs-audit': 'logs', + '/terminal-audit': 'terminal', + '/settings': 'settings', + }; + const [activeTab, setActiveTab] = useState(pathToTab[location.pathname] || 'users'); + + // URL 变化时同步 Tab(侧边栏菜单点击触发) // Logan.Li + useEffect(() => { + const tab = pathToTab[location.pathname]; + if (tab && tab !== activeTab) { + setActiveTab(tab); + } + }, [location.pathname]); const [searchQuery, setSearchQuery] = useState(''); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); @@ -238,6 +257,10 @@ export function SystemManager() { 操作审计 + + + 终端审计 + 系统设置 @@ -299,6 +322,10 @@ export function SystemManager() { /> + + + + diff --git a/frontend/src/components/system/SystemRolesTab.tsx b/frontend/src/components/system/SystemRolesTab.tsx index 1fbcfe5..fde7963 100644 --- a/frontend/src/components/system/SystemRolesTab.tsx +++ b/frontend/src/components/system/SystemRolesTab.tsx @@ -34,14 +34,15 @@ function normalizeRole(raw: any): Role { name: raw.name ?? '', code: raw.code ?? '', description: raw.description ?? '', - // 后端可能返回权限对象数组或字符串数组,统一映射为编码字符串数组 - permissions: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean), + // 后端返回权限对象数组(含 name/code/type),映射为显示名数组 + permissions: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.name ?? p?.code ?? '')).filter(Boolean), + permissionCodes: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean), createdAt: raw.createdAt ?? raw.created_at ?? '', // 保留原始权限 ID 供编辑使用 - _permissionIds: Array.isArray(raw.permissions) - ? raw.permissions.map((p: any) => (typeof p === 'object' ? Number(p.id) : 0)).filter(Boolean) - : [], - } as Role & { _permissionIds: number[] }; + _permissionIds: rawPerms + .map((p: any) => (typeof p === 'object' ? Number(p.id) : 0)) + .filter(Boolean), + } as Role & { _permissionIds: number[]; permissionCodes: string[] }; } const emptyForm: RoleFormData = { @@ -185,7 +186,7 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab ) : (
- {roles.map((role) => ( + {roles.map((role: any) => (
@@ -228,21 +229,25 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab

{role.description || '暂无描述'}

+ {/* 权限按模块分组显示 // Logan.Li */}
- {role.permissions.slice(0, 5).map((permission) => ( - + {(role.permissions as string[]).slice(0, 8).map((permission, idx) => ( + {permission} ))} - {role.permissions.length > 5 && ( - - +{role.permissions.length - 5} + {(role.permissions as string[]).length > 8 && ( + + +{(role.permissions as string[]).length - 8} 项 )} - {role.permissions.length === 0 && ( + {(role.permissions as string[]).length === 0 && ( 未分配权限 )}
+
+ 共 {role.permissions?.length || 0} 项权限 +
))} @@ -294,11 +299,11 @@ function RoleDetailDialog({

{role?.description || '暂无描述'}

-

权限列表

+

权限列表 ({role?.permissions?.length || 0} 项)

{role && role.permissions.length > 0 ? ( -
- {role.permissions.map((p) => ( - +
+ {role.permissions.map((p: string, idx: number) => ( + {p} ))} diff --git a/frontend/src/components/system/TerminalSessionsTab.tsx b/frontend/src/components/system/TerminalSessionsTab.tsx new file mode 100644 index 0000000..2230062 --- /dev/null +++ b/frontend/src/components/system/TerminalSessionsTab.tsx @@ -0,0 +1,334 @@ +import { useState, useEffect } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Search, RefreshCw, Trash2, Play } from 'lucide-react'; +import { terminalSessionApi } from '@/services/api'; +import { useConfirm } from '@/components/ui/confirm-dialog'; +import { ReplayDialog } from './ReplayDialog'; + +interface TerminalSession { + id: number; + user_id?: number; + username: string; + server_id: number; + server_name: string; + server_ip: string; + server_port: number; + client_ip: string; + status: string; + duration: number; + record_size: number; + start_time: string; + end_time?: string | null; +} + +function formatDuration(sec: number): string { + if (!sec || sec < 0) return '--'; + if (sec < 60) return `${sec}秒`; + const m = Math.floor(sec / 60); + const s = sec % 60; + if (m < 60) return `${m}分${s > 0 ? `${s}秒` : ''}`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}时${rm > 0 ? `${rm}分` : ''}`; +} + +function formatSize(bytes: number): string { + if (!bytes) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} + +/** + * 终端审计 Tab — WebSSH 会话录制列表与回放(JumpServer 式审计) // Logan.Li + */ +export function TerminalSessionsTab() { + const confirm = useConfirm(); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [keyword, setKeyword] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [selected, setSelected] = useState>(new Set()); + const [replayId, setReplayId] = useState(null); + const [replayOpen, setReplayOpen] = useState(false); + + const fetchSessions = async () => { + setLoading(true); + try { + const resp = await terminalSessionApi.list({ page: 1, page_size: 100 }); + setSessions(resp?.list ?? []); + } catch { + setSessions([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchSessions(); + }, []); + + const filtered = sessions.filter((s) => { + const kw = keyword.toLowerCase(); + const kwMatch = + !kw || + (s.username || '').toLowerCase().includes(kw) || + (s.server_name || '').toLowerCase().includes(kw) || + (s.server_ip || '').toLowerCase().includes(kw) || + (s.client_ip || '').toLowerCase().includes(kw); + const stMatch = !statusFilter || s.status === statusFilter; + return kwMatch && stMatch; + }); + + const getStatusBadge = (status: string) => { + if (status === 'active') { + return ( + + 活动中 + + ); + } + if (status === 'failed') { + return ( + + 失败 + + ); + } + return ( + + 已关闭 + + ); + }; + + const toggleSelect = (id: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleAll = () => { + setSelected((prev) => + prev.size === filtered.length ? new Set() : new Set(filtered.map((s) => s.id)) + ); + }; + + const openReplay = (id: number) => { + setReplayId(id); + setReplayOpen(true); + }; + + const handleDelete = async (id: number) => { + const ok = await confirm({ + title: '删除会话记录', + description: '确认删除该终端会话记录?删除后无法恢复。', + confirmText: '删除', + variant: 'destructive', + }); + if (!ok) return; + try { + await terminalSessionApi.delete(id); + setSelected((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + await fetchSessions(); + } catch { + /* noop */ + } + }; + + const handleBatchDelete = async () => { + if (selected.size === 0) return; + const ok = await confirm({ + title: '批量删除', + description: `确认删除选中的 ${selected.size} 条终端会话记录?`, + confirmText: '删除', + variant: 'destructive', + }); + if (!ok) return; + try { + await terminalSessionApi.batchDelete(Array.from(selected)); + setSelected(new Set()); + await fetchSessions(); + } catch { + /* noop */ + } + }; + + const allChecked = filtered.length > 0 && selected.size === filtered.length; + + return ( + <> + {/* Filters */} +
+
+ + setKeyword(e.target.value)} + className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" + /> +
+
+ {['', 'active', 'closed', 'failed'].map((st) => ( + + ))} +
+ + {selected.size > 0 && ( + + )} +
+ + {/* Sessions Table */} + + +
+ {loading ? ( +
+
+
+ ) : filtered.length === 0 ? ( +
+ +

暂无终端会话记录

+
+ ) : ( + + + + + + + 用户 + 服务器 + 来源 IP + 状态 + 时长 + 录制 + 开始时间 + 操作 + + + + {filtered.map((s) => ( + + + toggleSelect(s.id)} + className="accent-blue-600 cursor-pointer" + onClick={(e) => e.stopPropagation()} + /> + + + {s.username || '--'} + + +
+ {s.server_name || '--'} + + {s.server_ip}:{s.server_port} + +
+
+ + {s.client_ip || '--'} + + {getStatusBadge(s.status)} + + {formatDuration(s.duration)} + + + {formatSize(s.record_size)} + + + + {s.start_time ? new Date(s.start_time).toLocaleString() : '--'} + + + +
+ + +
+
+
+ ))} +
+
+ )} +
+ + + + + + ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ab2231a..5ef888f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -527,6 +527,25 @@ export const auditApi = { }, }; +// ==================== 终端会话审计 API(WebSSH 录制) ==================== // Logan.Li +export const terminalSessionApi = { + list: async (params?: { page?: number; page_size?: number; server_id?: number; username?: string; status?: string }) => { + return http.get('/terminal-sessions', params); + }, + get: async (id: number) => { + return http.get(`/terminal-sessions/${id}`); + }, + getRecord: async (id: number) => { + return http.get(`/terminal-sessions/${id}/record`); + }, + delete: async (id: number) => { + return http.delete(`/terminal-sessions/${id}`); + }, + batchDelete: async (ids: number[]) => { + return http.post('/terminal-sessions/batch-delete', { ids }); + }, +}; + // ==================== Ansible 自动化 API ==================== export const ansibleApi = { // Playbook 管理