feat: Agent CLI 与终端审计回放全面优化

- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏
- 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复
- 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示
- 审计历史页只读(移除删除),移除回放弹框录制搜索
This commit is contained in:
Logan committed 2026-07-01 12:13:53 +08:00
1 parent 9a3dd304f7
commit 1acb0c49af
81 files changed
+3916 -1655

No files matched your search

+74 -24
View File
@@ -190,23 +190,50 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
recorder := newAsciicastRecorder()
const maxRecordSize = 1024 * 1024
var cmdBuf strings.Builder
sessionStatus := "closed"
var finalizeOnce sync.Once
finalizeSession := func(status string) {
endTime := time.Now()
duration := int(endTime.Sub(termSession.StartTime).Seconds())
recData := recorder.String()
recSize := recorder.Len()
log.Printf("[WebShell] Finalize session=%d status=%s duration=%ds rec=%d bytes", termSession.ID, status, duration, recSize)
// Raw SQL with error logging
result := h.db.Exec("UPDATE sys_terminal_sessions SET status=?, duration=?, end_time=?, record_data=?, record_size=? WHERE id=?",
status, duration, endTime, recData, recSize, termSession.ID)
if result.Error != nil {
log.Printf("[WebShell] ERROR finalize session=%d: %v", termSession.ID, result.Error)
} else {
log.Printf("[WebShell] Finalize OK session=%d rows_affected=%d", termSession.ID, result.RowsAffected)
// 命令输出字节统计:每条命令对应「下一条命令前」的 stdout 累计量 // Logan.Li
var cmdTrack struct {
mu sync.Mutex
lastCmdID uint
stdoutBytes int
}
flushCmdOutput := func() {
cmdTrack.mu.Lock()
prevID := cmdTrack.lastCmdID
bytes := cmdTrack.stdoutBytes
cmdTrack.stdoutBytes = 0
cmdTrack.mu.Unlock()
if prevID > 0 {
_ = h.db.Model(&model.SessionCommand{}).Where("id = ?", prevID).
Update("output_size", bytes).Error
}
}
// finalizeSession 会话结束落盘(sync.Once 防重复写入) // Logan.Li
finalizeSession := func(status string) {
sessionStatus = status
finalizeOnce.Do(func() {
endTime := time.Now()
duration := int(endTime.Sub(termSession.StartTime).Seconds())
recData := recorder.String()
recSize := recorder.Len()
log.Printf("[WebShell] Finalize session=%d status=%s duration=%ds rec=%d bytes", termSession.ID, status, duration, recSize)
result := h.db.Exec("UPDATE sys_terminal_sessions SET status=?, duration=?, end_time=?, record_data=?, record_size=? WHERE id=?",
status, duration, endTime, recData, recSize, termSession.ID)
if result.Error != nil {
log.Printf("[WebShell] ERROR finalize session=%d: %v", termSession.ID, result.Error)
} else {
log.Printf("[WebShell] Finalize OK session=%d rows_affected=%d", termSession.ID, result.RowsAffected)
}
})
}
defer func() {
flushCmdOutput()
finalizeSession(sessionStatus)
}()
connectingMsg := "\r\n\x1b[36mConnecting to " + server.Hostname + " (" + server.IP + ":" + strconv.Itoa(server.Port) + ")...\x1b[0m\r\n"
conn.WriteJSON(WSMessage{Status: 0, Data: connectingMsg})
@@ -335,6 +362,9 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
if recorder.Len() < maxRecordSize {
recorder.WriteRow(data)
}
cmdTrack.mu.Lock()
cmdTrack.stdoutBytes += n
cmdTrack.mu.Unlock()
if h.auditHub != nil {
h.auditHub.Broadcast(termSession.ID, data)
}
@@ -366,13 +396,21 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
case 0:
if msg.Data != "" {
stdinPipe.Write([]byte(msg.Data))
// 录入用户输入,回放可见键入内容 // Logan.Li
if recorder.Len() < maxRecordSize {
recorder.WriteRow([]byte(msg.Data))
}
// Command extraction from stdin
cmdBuf.WriteString(msg.Data)
if strings.Contains(msg.Data, "\r") || strings.Contains(msg.Data, "\n") {
cmd := extractCommand(cmdBuf.String())
if cmd != "" {
recordCommand(h.db, termSession.ID, termSession.ServerID, usernameStr, cmd)
flushCmdOutput()
newID := recordCommand(h.db, termSession.ID, termSession.ServerID, termSession.ServerName, usernameStr, cmd)
cmdTrack.mu.Lock()
cmdTrack.lastCmdID = newID
cmdTrack.mu.Unlock()
}
cmdBuf.Reset()
}
@@ -392,10 +430,16 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
sshClient.Close()
<-done
stdinPipe.Close()
finalizeSession("closed")
sessionStatus = "closed"
log.Printf("[WebShell] Disconnected %s (%s)", server.Hostname, addr)
}
// shortShellCmds 常见 2 字符 shell 命令白名单 // Logan.Li
var shortShellCmds = map[string]bool{
"ls": true, "cd": true, "ps": true, "df": true, "id": true, "wc": true,
"mv": true, "cp": true, "vi": true, "sh": true, "ll": true, "pwd": true,
}
// extractCommand extracts a clean command from raw terminal input
func extractCommand(input string) string {
cleaned := strings.Map(func(r rune) rune {
@@ -411,25 +455,31 @@ func extractCommand(input string) string {
if len(cleaned) < 2 || len(cleaned) > 500 {
return ""
}
// 过滤输入中途误触回车产生的 2 字符残片(如 up/uo),保留 ls/cd 等 // Logan.Li
if len(cleaned) == 2 && !shortShellCmds[cleaned] {
return ""
}
if strings.HasPrefix(cleaned, "\x1b[") {
return ""
}
return cleaned
}
// recordCommand records a terminal command to sys_session_commands
func recordCommand(db *gorm.DB, sessionID, serverID uint, username, command string) {
// recordCommand 写入命令审计并返回记录 ID // Logan.Li
func recordCommand(db *gorm.DB, sessionID, serverID uint, serverName, username, command string) uint {
now := time.Now()
cmd := model.SessionCommand{
SessionID: sessionID,
ServerID: serverID,
Username: username,
Command: command,
Timestamp: now,
SessionID: sessionID,
ServerID: serverID,
ServerName: serverName,
Username: username,
Command: command,
Timestamp: now,
}
if err := db.Create(&cmd).Error; err != nil {
log.Printf("[WebShell] Command record failed: %v", err)
} else {
log.Printf("[WebShell] Command recorded session=%d cmd=%q", sessionID, command)
return 0
}
log.Printf("[WebShell] Command recorded session=%d cmd=%q", sessionID, command)
return cmd.ID
}