feat: 完善堡垒机与 Agent 智能运维能力
- 支持 Windows Agent 构建、安装脚本和统一上报协议 - 增强 RDP 剪贴板、虚拟盘和文件传输能力 - 新增 AIOps 上下文工具、告警 RCA trace 落库和回放接口 - 重构 docs 模块文档并补充终端、堡垒机、监控、AI Agent 设计
This commit is contained in:
1 parent
a9513c2056
commit
e0d652d211
169 files changed
+12192
-9116
No files matched your search
@@ -22,6 +22,7 @@ import (
|
||||
"ezops/pkg/agentcli/llm"
|
||||
"ezops/pkg/agentcli/safety"
|
||||
"ezops/pkg/agentcli/tools"
|
||||
"ezops/pkg/aiopscontext"
|
||||
"ezops/pkg/remoteexec"
|
||||
)
|
||||
|
||||
@@ -54,11 +55,11 @@ type wsEvent struct {
|
||||
|
||||
// wsControl 前端→后端控制消息 // Logan.Li
|
||||
type wsControl struct {
|
||||
Type string `json:"type"` // start/continue/resume/confirm/deny/abort
|
||||
Query string `json:"query,omitempty"` // start/continue 时任务描述
|
||||
SessionID uint `json:"session_id,omitempty"` // resume 时恢复的历史会话 ID
|
||||
SafetyMode string `json:"safety_mode,omitempty"` // strict/balanced/permissive
|
||||
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
|
||||
Type string `json:"type"` // start/continue/resume/confirm/deny/abort
|
||||
Query string `json:"query,omitempty"` // start/continue 时任务描述
|
||||
SessionID uint `json:"session_id,omitempty"` // resume 时恢复的历史会话 ID
|
||||
SafetyMode string `json:"safety_mode,omitempty"` // strict/balanced/permissive
|
||||
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
|
||||
}
|
||||
|
||||
// writeWSCloseError 在 agentConn 建立前,直接通过 conn 推送一条 error 事件再关闭连接 // Logan.Li
|
||||
@@ -75,24 +76,24 @@ func writeWSCloseError(conn *websocket.Conn, msg string) {
|
||||
|
||||
// agentConn 单个 WS 连接的运行态 // Logan.Li
|
||||
type agentConn struct {
|
||||
conn *websocket.Conn
|
||||
db *gorm.DB
|
||||
userID uint
|
||||
mode string
|
||||
server model.Server
|
||||
sshCfg remoteexec.SSHConfig
|
||||
conn *websocket.Conn
|
||||
db *gorm.DB
|
||||
userID uint
|
||||
mode string
|
||||
server model.Server
|
||||
sshCfg remoteexec.SSHConfig
|
||||
kubernetesCtx *kubernetesAgentContext
|
||||
session *model.AgentCLISession
|
||||
taskCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
totalTokens int
|
||||
tokenMu sync.Mutex
|
||||
writeMu sync.Mutex
|
||||
confirms map[string]chan bool
|
||||
cfMu sync.Mutex
|
||||
convHistory []llm.Message
|
||||
running bool
|
||||
runningMu sync.Mutex
|
||||
session *model.AgentCLISession
|
||||
taskCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
totalTokens int
|
||||
tokenMu sync.Mutex
|
||||
writeMu sync.Mutex
|
||||
confirms map[string]chan bool
|
||||
cfMu sync.Mutex
|
||||
convHistory []llm.Message
|
||||
running bool
|
||||
runningMu sync.Mutex
|
||||
}
|
||||
|
||||
// Run WebSocket 入口 // Logan.Li
|
||||
@@ -362,6 +363,7 @@ func (ac *agentConn) runTask(sess *model.AgentCLISession, query, safetyMode stri
|
||||
SafetyMode: parseSafetyMode(safetyMode),
|
||||
MemoryDir: fmt.Sprintf("data/agentcli/user_%d", ac.userID),
|
||||
Core: coreCfg,
|
||||
ExtraTools: aiopscontext.NewAgentTools(aiopscontext.NewService(ac.db)),
|
||||
}
|
||||
if ac.mode == agentcli.RunModeKubernetes {
|
||||
if ac.kubernetesCtx == nil {
|
||||
@@ -604,4 +606,4 @@ func (ac *agentConn) persistMsg(sid uint, round int, typ, role, content, toolNam
|
||||
// 持久化失败不阻断主流程
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/middleware"
|
||||
"ezops/model"
|
||||
"ezops/pkg/agentcli/llm"
|
||||
"ezops/pkg/aiopscontext"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AIOpsAlertAnalysisHandler 基于上下文包执行告警 RCA。 // Logan.Li
|
||||
type AIOpsAlertAnalysisHandler struct {
|
||||
db *gorm.DB
|
||||
context *aiopscontext.Service
|
||||
}
|
||||
|
||||
// NewAIOpsAlertAnalysisHandler 创建告警分析处理器。 // Logan.Li
|
||||
func NewAIOpsAlertAnalysisHandler(db *gorm.DB) *AIOpsAlertAnalysisHandler {
|
||||
return &AIOpsAlertAnalysisHandler{db: db, context: aiopscontext.NewService(db)}
|
||||
}
|
||||
|
||||
// AlertAnalysisRequest 告警分析请求,前端传告警 ID 和少量兜底字段。 // Logan.Li
|
||||
type AlertAnalysisRequest struct {
|
||||
AlertID uint `json:"alert_id"`
|
||||
Name string `json:"name"`
|
||||
Severity string `json:"severity"`
|
||||
Target string `json:"target"`
|
||||
Metric string `json:"metric"`
|
||||
CurrentValue string `json:"current_value"`
|
||||
Threshold string `json:"threshold"`
|
||||
Duration string `json:"duration"`
|
||||
Description string `json:"description"`
|
||||
TimeRange string `json:"time_range"`
|
||||
}
|
||||
|
||||
// AlertAnalysisResponse 告警 RCA 结构化响应。 // Logan.Li
|
||||
type AlertAnalysisResponse struct {
|
||||
TraceID string `json:"trace_id"`
|
||||
SpanID string `json:"span_id"`
|
||||
RootCause string `json:"root_cause"`
|
||||
Impact string `json:"impact"`
|
||||
Recommendations []string `json:"recommendations"`
|
||||
AutoFixScript string `json:"auto_fix_script,omitempty"`
|
||||
SimilarCases int `json:"similar_cases"`
|
||||
Confidence int `json:"confidence"`
|
||||
Evidence []aiopscontext.EvidenceItem `json:"evidence"`
|
||||
ContextPack *aiopscontext.ContextPack `json:"context_pack,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Source string `json:"source"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
// Analyze POST /api/v1/aiops/alert-analysis。 // Logan.Li
|
||||
func (h *AIOpsAlertAnalysisHandler) Analyze(c *gin.Context) {
|
||||
start := time.Now()
|
||||
traceID := uuid.NewString()
|
||||
spanID := uuid.NewString()
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var req AlertAnalysisRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
timeRange := req.TimeRange
|
||||
if strings.TrimSpace(timeRange) == "" {
|
||||
timeRange = "24h"
|
||||
}
|
||||
metrics := metricsForAlert(req.Metric)
|
||||
pack, err := h.context.BuildPack(c.Request.Context(), aiopscontext.ContextPackRequest{
|
||||
AlertID: req.AlertID, Target: req.Target, Metrics: metrics, TimeRange: timeRange, Limit: 12,
|
||||
})
|
||||
if err != nil {
|
||||
h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{
|
||||
TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis",
|
||||
Target: req.Target, AlertID: req.AlertID, Status: "error", Source: "context-pack",
|
||||
Duration: time.Since(start).Milliseconds(), Error: err.Error(),
|
||||
Result: gin.H{"error": "构建上下文失败: " + err.Error()},
|
||||
})
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "构建上下文失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
fallback := fallbackAlertAnalysis(req, pack)
|
||||
attachTrace(fallback, traceID, spanID)
|
||||
var cfg model.LLMConfig
|
||||
if err := h.db.First(&cfg).Error; err != nil || !cfg.Enabled {
|
||||
h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{
|
||||
TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis",
|
||||
Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider,
|
||||
Model: fallback.Model, Source: fallback.Source, Status: "fallback", Pack: pack, Result: fallback,
|
||||
Duration: time.Since(start).Milliseconds(),
|
||||
})
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: fallback})
|
||||
return
|
||||
}
|
||||
|
||||
result, usage, err := h.callAlertRCA(c.Request.Context(), cfg, req, pack, fallback)
|
||||
if err != nil {
|
||||
fallback.Source = "rule-fallback: " + err.Error()
|
||||
attachTrace(fallback, traceID, spanID)
|
||||
h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{
|
||||
TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis",
|
||||
Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider,
|
||||
Model: cfg.Model, Source: "rule-fallback", Status: "fallback", Pack: pack, Result: fallback,
|
||||
Duration: time.Since(start).Milliseconds(), Error: err.Error(),
|
||||
})
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: fallback})
|
||||
return
|
||||
}
|
||||
attachTrace(result, traceID, spanID)
|
||||
h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{
|
||||
TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis",
|
||||
Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider,
|
||||
Model: result.Model, Source: result.Source, Status: "success", Pack: pack, Result: result,
|
||||
PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens,
|
||||
Duration: time.Since(start).Milliseconds(),
|
||||
})
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
func (h *AIOpsAlertAnalysisHandler) callAlertRCA(ctx context.Context, cfg model.LLMConfig, req AlertAnalysisRequest, pack *aiopscontext.ContextPack, fallback *AlertAnalysisResponse) (*AlertAnalysisResponse, llm.Usage, error) {
|
||||
packJSON, _ := json.MarshalIndent(pack, "", " ")
|
||||
prompt := fmt.Sprintf(`你是 AIOps 平台的企业级告警 RCA 分析器。请只基于【告警基本信息】和【AIOps Context Pack】做判断,不要编造不存在的数据。
|
||||
|
||||
【告警基本信息】
|
||||
- ID: %d
|
||||
- 名称: %s
|
||||
- 级别: %s
|
||||
- 目标: %s
|
||||
- 指标: %s
|
||||
- 当前值: %s
|
||||
- 阈值: %s
|
||||
- 持续: %s
|
||||
- 描述: %s
|
||||
|
||||
【AIOps Context Pack】
|
||||
%s
|
||||
|
||||
请返回严格 JSON,不要 markdown,不要代码块,字段如下:
|
||||
{
|
||||
"root_cause": "根因分析,2-4句,必须引用证据",
|
||||
"impact": "影响范围,1-2句",
|
||||
"recommendations": ["建议1", "建议2", "建议3"],
|
||||
"auto_fix_script": "仅限只读诊断脚本;没有则为空字符串",
|
||||
"similar_cases": 0,
|
||||
"confidence": 0-100
|
||||
}`, req.AlertID, req.Name, req.Severity, req.Target, req.Metric, req.CurrentValue, req.Threshold, req.Duration, req.Description, string(packJSON))
|
||||
|
||||
resp, err := buildLLMClientFromConfig(cfg).Complete(ctx, &llm.CompletionRequest{
|
||||
Messages: []llm.Message{
|
||||
{Role: "system", Content: "你是 AIOps 平台的告警根因分析专家,输出必须是可解析 JSON。"},
|
||||
{Role: "user", Content: prompt},
|
||||
},
|
||||
Temperature: 0.2,
|
||||
MaxTokens: cfg.MaxTokens,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, llm.Usage{}, err
|
||||
}
|
||||
parsed := *fallback
|
||||
if err := parseAlertAnalysisJSON(resp.Message.Content, &parsed); err != nil {
|
||||
parsed.RootCause = strings.TrimSpace(resp.Message.Content)
|
||||
}
|
||||
parsed.ContextPack = pack
|
||||
parsed.Evidence = pack.Evidence
|
||||
parsed.Model = cfg.Model
|
||||
parsed.Source = "llm"
|
||||
parsed.GeneratedAt = time.Now()
|
||||
return &parsed, resp.Usage, nil
|
||||
}
|
||||
|
||||
// ListAnalysisTraces 查询 AI 分析审计记录,普通用户只看自己的记录。 // Logan.Li
|
||||
func (h *AIOpsAlertAnalysisHandler) ListAnalysisTraces(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if pageSize <= 0 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
q := h.db.Model(&model.AIAnalysisTrace{})
|
||||
if !middleware.GetIsAdmin(c) {
|
||||
q = q.Where("user_id = ?", middleware.GetUserID(c))
|
||||
}
|
||||
if feature := strings.TrimSpace(c.Query("feature")); feature != "" {
|
||||
q = q.Where("feature = ?", feature)
|
||||
}
|
||||
if target := strings.TrimSpace(c.Query("target")); target != "" {
|
||||
q = q.Where("target LIKE ?", "%"+target+"%")
|
||||
}
|
||||
if status := strings.TrimSpace(c.Query("status")); status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var rows []model.AIAnalysisTrace
|
||||
if err := q.Select("id, trace_id, span_id, parent_span_id, user_id, feature, target, alert_id, provider, model, source, status, evidence_count, prompt_tokens, completion_tokens, total_tokens, duration, error, created_at, updated_at").
|
||||
Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "查询失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": rows, "total": total}})
|
||||
}
|
||||
|
||||
// GetAnalysisTrace 按 trace_id 回放某次 AI 分析看到的上下文包和输出结论。 // Logan.Li
|
||||
func (h *AIOpsAlertAnalysisHandler) GetAnalysisTrace(c *gin.Context) {
|
||||
traceID := strings.TrimSpace(c.Param("trace_id"))
|
||||
q := h.db.Where("trace_id = ?", traceID)
|
||||
if !middleware.GetIsAdmin(c) {
|
||||
q = q.Where("user_id = ?", middleware.GetUserID(c))
|
||||
}
|
||||
var row model.AIAnalysisTrace
|
||||
if err := q.Order("id ASC").First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "分析记录不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
||||
"trace": row,
|
||||
"context_pack": decodeJSONField(row.ContextPack),
|
||||
"result": decodeJSONField(row.Result),
|
||||
}})
|
||||
}
|
||||
|
||||
func parseAlertAnalysisJSON(raw string, out *AlertAnalysisResponse) error {
|
||||
clean := strings.TrimSpace(raw)
|
||||
clean = strings.TrimPrefix(clean, "```json")
|
||||
clean = strings.TrimPrefix(clean, "```")
|
||||
clean = strings.TrimSuffix(clean, "```")
|
||||
clean = strings.TrimSpace(clean)
|
||||
var tmp struct {
|
||||
RootCause string `json:"root_cause"`
|
||||
Impact string `json:"impact"`
|
||||
Recommendations []string `json:"recommendations"`
|
||||
AutoFixScript string `json:"auto_fix_script"`
|
||||
SimilarCases int `json:"similar_cases"`
|
||||
Confidence int `json:"confidence"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(clean), &tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(tmp.RootCause) != "" {
|
||||
out.RootCause = tmp.RootCause
|
||||
}
|
||||
if strings.TrimSpace(tmp.Impact) != "" {
|
||||
out.Impact = tmp.Impact
|
||||
}
|
||||
if len(tmp.Recommendations) > 0 {
|
||||
out.Recommendations = tmp.Recommendations
|
||||
}
|
||||
out.AutoFixScript = tmp.AutoFixScript
|
||||
out.SimilarCases = tmp.SimilarCases
|
||||
if tmp.Confidence > 0 {
|
||||
if tmp.Confidence > 100 {
|
||||
tmp.Confidence = 100
|
||||
}
|
||||
out.Confidence = tmp.Confidence
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fallbackAlertAnalysis(req AlertAnalysisRequest, pack *aiopscontext.ContextPack) *AlertAnalysisResponse {
|
||||
evidenceText := "暂无足够平台证据"
|
||||
if len(pack.Evidence) > 0 {
|
||||
evidenceText = pack.Evidence[0].Summary
|
||||
}
|
||||
target := firstText(req.Target, pack.Scope, "未知目标")
|
||||
metric := firstText(req.Metric, "unknown")
|
||||
recs := []string{
|
||||
fmt.Sprintf("先查看 %s 的近期指标趋势和告警时间线,确认是否持续异常", target),
|
||||
"结合变更、发布、定时任务和日志错误进一步缩小根因范围",
|
||||
"修复动作前先执行只读诊断,涉及重启、清理、扩容等动作需走确认或审批",
|
||||
}
|
||||
return &AlertAnalysisResponse{
|
||||
RootCause: fmt.Sprintf("告警 %s 在目标 %s 上触发,指标 %s 当前值 %s,阈值 %s。基于当前上下文,最直接证据是:%s。",
|
||||
firstText(req.Name, "未命名告警"), target, metric, req.CurrentValue, req.Threshold, evidenceText),
|
||||
Impact: fmt.Sprintf("该告警级别为 %s,可能影响目标 %s 的稳定性,需要结合业务等级判断优先级。", firstText(req.Severity, "unknown"), target),
|
||||
Recommendations: recs,
|
||||
SimilarCases: 0,
|
||||
Confidence: fallbackConfidence(pack),
|
||||
Evidence: pack.Evidence,
|
||||
ContextPack: pack,
|
||||
Model: "rule-fallback",
|
||||
Source: "rule-fallback",
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func firstText(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func metricsForAlert(metric string) []string {
|
||||
m := strings.ToLower(metric)
|
||||
switch {
|
||||
case strings.Contains(m, "mem"):
|
||||
return []string{"mem_usage", "load1"}
|
||||
case strings.Contains(m, "load"):
|
||||
return []string{"load1", "load5", "cpu_usage"}
|
||||
case strings.Contains(m, "io"):
|
||||
return []string{"cpu_iowait", "load1"}
|
||||
default:
|
||||
return []string{"cpu_usage", "mem_usage", "load1"}
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackConfidence(pack *aiopscontext.ContextPack) int {
|
||||
score := 45
|
||||
if len(pack.Alerts) > 0 {
|
||||
score += 10
|
||||
}
|
||||
if len(pack.Metrics) > 0 {
|
||||
score += 15
|
||||
}
|
||||
if len(pack.Assets) > 0 {
|
||||
score += 5
|
||||
}
|
||||
if score > 80 {
|
||||
score = 80
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
type analysisTraceInput struct {
|
||||
TraceID string
|
||||
SpanID string
|
||||
ParentSpanID string
|
||||
UserID uint
|
||||
Feature string
|
||||
Target string
|
||||
AlertID uint
|
||||
Provider string
|
||||
Model string
|
||||
Source string
|
||||
Status string
|
||||
Pack *aiopscontext.ContextPack
|
||||
Result interface{}
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
Duration int64
|
||||
Error string
|
||||
}
|
||||
|
||||
func attachTrace(resp *AlertAnalysisResponse, traceID, spanID string) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
resp.TraceID = traceID
|
||||
resp.SpanID = spanID
|
||||
}
|
||||
|
||||
func (h *AIOpsAlertAnalysisHandler) saveAnalysisTrace(ctx context.Context, in analysisTraceInput) {
|
||||
packJSON := marshalTraceJSON(in.Pack)
|
||||
resultJSON := marshalTraceJSON(traceResultForStorage(in.Result))
|
||||
evidenceCount := 0
|
||||
if in.Pack != nil {
|
||||
evidenceCount = len(in.Pack.Evidence)
|
||||
}
|
||||
row := model.AIAnalysisTrace{
|
||||
TraceID: in.TraceID,
|
||||
SpanID: in.SpanID,
|
||||
ParentSpanID: in.ParentSpanID,
|
||||
UserID: in.UserID,
|
||||
Feature: firstText(in.Feature, "unknown"),
|
||||
Target: in.Target,
|
||||
AlertID: in.AlertID,
|
||||
Provider: in.Provider,
|
||||
Model: in.Model,
|
||||
Source: trimTraceText(in.Source, 64),
|
||||
Status: firstText(in.Status, "unknown"),
|
||||
ContextPack: packJSON,
|
||||
Result: resultJSON,
|
||||
EvidenceCount: evidenceCount,
|
||||
PromptTokens: in.PromptTokens,
|
||||
CompletionTokens: in.CompletionTokens,
|
||||
TotalTokens: in.TotalTokens,
|
||||
Duration: in.Duration,
|
||||
Error: in.Error,
|
||||
}
|
||||
_ = h.db.WithContext(ctx).Create(&row).Error
|
||||
}
|
||||
|
||||
func marshalTraceJSON(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func traceResultForStorage(v interface{}) interface{} {
|
||||
switch r := v.(type) {
|
||||
case *AlertAnalysisResponse:
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *r
|
||||
cp.ContextPack = nil
|
||||
return cp
|
||||
case AlertAnalysisResponse:
|
||||
r.ContextPack = nil
|
||||
return r
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSONField(raw string) interface{} {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var out interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trimTraceText(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max])
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/aiopscontext"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AIOpsContextHandler 提供 AI 上下文包接口,供智能助手、告警分析、预测分析和后续 MCP 复用。 // Logan.Li
|
||||
type AIOpsContextHandler struct {
|
||||
service *aiopscontext.Service
|
||||
}
|
||||
|
||||
// NewAIOpsContextHandler 创建 AI 上下文处理器。 // Logan.Li
|
||||
func NewAIOpsContextHandler(db *gorm.DB) *AIOpsContextHandler {
|
||||
return &AIOpsContextHandler{service: aiopscontext.NewService(db)}
|
||||
}
|
||||
|
||||
// Summary GET /api/v1/aiops/context/summary // Logan.Li
|
||||
func (h *AIOpsContextHandler) Summary(c *gin.Context) {
|
||||
data, err := h.service.Summary(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: data})
|
||||
}
|
||||
|
||||
// BuildPack POST /api/v1/aiops/context/pack // Logan.Li
|
||||
func (h *AIOpsContextHandler) BuildPack(c *gin.Context) {
|
||||
var req aiopscontext.ContextPackRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
data, err := h.service.BuildPack(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: data})
|
||||
}
|
||||
@@ -30,10 +30,35 @@ func (h *AuditSettingsHandler) getOrCreate() (model.AuditSettings, error) {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return s, err
|
||||
}
|
||||
s = model.AuditSettings{RetentionDays: 180, RecordAll: true}
|
||||
s = model.AuditSettings{
|
||||
RetentionDays: 180,
|
||||
RecordAll: true,
|
||||
SessionWatermarkEnabled: true,
|
||||
SessionRecordEnabled: true,
|
||||
ClipboardEnabled: true,
|
||||
FileUploadEnabled: true,
|
||||
FileDownloadEnabled: true,
|
||||
}
|
||||
return s, h.db.Create(&s).Error
|
||||
}
|
||||
|
||||
func auditSettingsPayload(s model.AuditSettings) gin.H {
|
||||
return gin.H{
|
||||
"retention_days": s.RetentionDays,
|
||||
"record_all": s.RecordAll,
|
||||
"session_watermark_enabled": s.SessionWatermarkEnabled,
|
||||
"session_record_enabled": s.SessionRecordEnabled,
|
||||
"clipboard_enabled": s.ClipboardEnabled,
|
||||
"file_upload_enabled": s.FileUploadEnabled,
|
||||
"file_download_enabled": s.FileDownloadEnabled,
|
||||
"command_policy_enabled": s.CommandPolicyEnabled,
|
||||
"approval_required": s.ApprovalRequired,
|
||||
"last_cleanup_at": s.LastCleanupAt,
|
||||
"last_cleanup_summary": s.LastCleanupSummary,
|
||||
"allowed_retention_days": model.AllowedAuditRetentionDays,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuditSettings 获取审计策略 // Logan.Li
|
||||
// GET /api/v1/admin/audit-settings
|
||||
func (h *AuditSettingsHandler) GetAuditSettings(c *gin.Context) {
|
||||
@@ -45,13 +70,7 @@ func (h *AuditSettingsHandler) GetAuditSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Message: "success",
|
||||
Data: gin.H{
|
||||
"retention_days": s.RetentionDays,
|
||||
"record_all": s.RecordAll,
|
||||
"last_cleanup_at": s.LastCleanupAt,
|
||||
"last_cleanup_summary": s.LastCleanupSummary,
|
||||
"allowed_retention_days": model.AllowedAuditRetentionDays,
|
||||
},
|
||||
Data: auditSettingsPayload(s),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,8 +78,15 @@ func (h *AuditSettingsHandler) GetAuditSettings(c *gin.Context) {
|
||||
// PUT /api/v1/admin/audit-settings
|
||||
func (h *AuditSettingsHandler) ApplyAuditSettings(c *gin.Context) {
|
||||
var req struct {
|
||||
RetentionDays int `json:"retention_days" binding:"required"`
|
||||
RecordAll bool `json:"record_all"`
|
||||
RetentionDays int `json:"retention_days" binding:"required"`
|
||||
RecordAll bool `json:"record_all"`
|
||||
SessionWatermarkEnabled bool `json:"session_watermark_enabled"`
|
||||
SessionRecordEnabled bool `json:"session_record_enabled"`
|
||||
ClipboardEnabled bool `json:"clipboard_enabled"`
|
||||
FileUploadEnabled bool `json:"file_upload_enabled"`
|
||||
FileDownloadEnabled bool `json:"file_download_enabled"`
|
||||
CommandPolicyEnabled bool `json:"command_policy_enabled"`
|
||||
ApprovalRequired bool `json:"approval_required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"})
|
||||
@@ -82,6 +108,13 @@ func (h *AuditSettingsHandler) ApplyAuditSettings(c *gin.Context) {
|
||||
|
||||
s.RetentionDays = req.RetentionDays
|
||||
s.RecordAll = req.RecordAll
|
||||
s.SessionWatermarkEnabled = req.SessionWatermarkEnabled
|
||||
s.SessionRecordEnabled = req.SessionRecordEnabled
|
||||
s.ClipboardEnabled = req.ClipboardEnabled
|
||||
s.FileUploadEnabled = req.FileUploadEnabled
|
||||
s.FileDownloadEnabled = req.FileDownloadEnabled
|
||||
s.CommandPolicyEnabled = req.CommandPolicyEnabled
|
||||
s.ApprovalRequired = req.ApprovalRequired
|
||||
|
||||
cleanResult, cleanErr := auditcleaner.Run(h.db, req.RetentionDays)
|
||||
now := time.Now()
|
||||
@@ -111,11 +144,18 @@ func (h *AuditSettingsHandler) ApplyAuditSettings(c *gin.Context) {
|
||||
Code: 200,
|
||||
Message: msg,
|
||||
Data: gin.H{
|
||||
"retention_days": s.RetentionDays,
|
||||
"record_all": s.RecordAll,
|
||||
"last_cleanup_at": s.LastCleanupAt,
|
||||
"last_cleanup_summary": s.LastCleanupSummary,
|
||||
"cleanup": cleanResult,
|
||||
"retention_days": s.RetentionDays,
|
||||
"record_all": s.RecordAll,
|
||||
"session_watermark_enabled": s.SessionWatermarkEnabled,
|
||||
"session_record_enabled": s.SessionRecordEnabled,
|
||||
"clipboard_enabled": s.ClipboardEnabled,
|
||||
"file_upload_enabled": s.FileUploadEnabled,
|
||||
"file_download_enabled": s.FileDownloadEnabled,
|
||||
"command_policy_enabled": s.CommandPolicyEnabled,
|
||||
"approval_required": s.ApprovalRequired,
|
||||
"last_cleanup_at": s.LastCleanupAt,
|
||||
"last_cleanup_summary": s.LastCleanupSummary,
|
||||
"cleanup": cleanResult,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -291,7 +291,7 @@ func (h *AutomationHandler) ToggleCronJob(c *gin.Context) {
|
||||
// @Router /cron-jobs/{id}/execute [post]
|
||||
func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
execution, err := RunCronJobByID(h.db, uint(id))
|
||||
execution, err := QueueCronJobByID(h.db, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{
|
||||
Code: 404,
|
||||
@@ -299,13 +299,9 @@ func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
msg := "执行成功"
|
||||
if execution.Status == "failed" {
|
||||
msg = "执行失败"
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Message: msg,
|
||||
Message: "已提交后台执行",
|
||||
Data: execution,
|
||||
})
|
||||
}
|
||||
@@ -299,45 +299,41 @@ func (h *AutomationHandler) ExecuteScript(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
go h.finishScriptExecution(script, execution.ID)
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Message: "已提交后台执行",
|
||||
Data: execution,
|
||||
})
|
||||
}
|
||||
|
||||
// finishScriptExecution 在后台完成脚本执行,HTTP 请求只负责提交任务 // Logan.Li
|
||||
func (h *AutomationHandler) finishScriptExecution(script model.Script, executionID uint) {
|
||||
rt := scriptrunner.DetectRuntime()
|
||||
if ok, reason := scriptrunner.CanRun(rt, script.Type); !ok {
|
||||
c.JSON(http.StatusBadRequest, model.Response{
|
||||
Code: 400,
|
||||
Message: reason,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 在 AIOps 本机执行(embed 单二进制 / Docker 容器内)
|
||||
output, exitCode, duration := runLocal(script.Type, script.Content, script.Timeout)
|
||||
finishedAt := time.Now()
|
||||
status := "success"
|
||||
if exitCode != 0 {
|
||||
output := ""
|
||||
exitCode := 0
|
||||
duration := 0
|
||||
if ok, reason := scriptrunner.CanRun(rt, script.Type); !ok {
|
||||
status = "failed"
|
||||
exitCode = -1
|
||||
output = reason + "\n"
|
||||
} else {
|
||||
output, exitCode, duration = runLocal(script.Type, script.Content, script.Timeout)
|
||||
if exitCode != 0 {
|
||||
status = "failed"
|
||||
}
|
||||
}
|
||||
|
||||
h.db.Model(&execution).Updates(map[string]interface{}{
|
||||
finishedAt := time.Now()
|
||||
h.db.Model(&model.TaskExecution{}).Where("id = ?", executionID).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"output": output,
|
||||
"duration": duration,
|
||||
"finished_at": finishedAt,
|
||||
})
|
||||
h.db.Model(&script).UpdateColumn("run_count", script.RunCount+1)
|
||||
|
||||
execution.Output = output
|
||||
execution.Status = status
|
||||
execution.Duration = duration
|
||||
execution.FinishedAt = &finishedAt
|
||||
|
||||
msg := "执行成功"
|
||||
if status == "failed" {
|
||||
msg = "执行失败"
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Message: msg,
|
||||
Data: execution,
|
||||
})
|
||||
h.db.Model(&model.Script{}).Where("id = ?", script.ID).UpdateColumn("run_count", script.RunCount+1)
|
||||
_ = exitCode
|
||||
}
|
||||
|
||||
// ListExecutions 获取任务执行记录列表
|
||||
|
||||
@@ -21,7 +21,49 @@ func RunCronJobByID(db *gorm.DB, id uint) (*model.TaskExecution, error) {
|
||||
return runCronJob(db, job)
|
||||
}
|
||||
|
||||
// QueueCronJobByID 提交一次手动执行,立即返回执行记录,后台继续执行 // Logan.Li
|
||||
func QueueCronJobByID(db *gorm.DB, id uint) (*model.TaskExecution, error) {
|
||||
var job model.CronJob
|
||||
if err := db.First(&job, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("定时任务不存在")
|
||||
}
|
||||
execution, startedAt, err := createCronExecution(db, job)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go finishCronExecution(db, job, execution.ID, startedAt)
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
execution, startedAt, err := createCronExecution(db, job)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finishCronExecution(db, job, execution.ID, startedAt)
|
||||
_ = db.First(execution, execution.ID).Error
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
func createCronExecution(db *gorm.DB, job model.CronJob) (*model.TaskExecution, time.Time, error) {
|
||||
startedAt := time.Now()
|
||||
execution := &model.TaskExecution{
|
||||
TaskType: "cron",
|
||||
TaskID: job.ID,
|
||||
TaskName: job.Name,
|
||||
Status: "running",
|
||||
TargetType: job.TargetType,
|
||||
Targets: job.Targets,
|
||||
StartedAt: &startedAt,
|
||||
}
|
||||
if err := db.Create(execution).Error; err != nil {
|
||||
return nil, startedAt, fmt.Errorf("创建执行记录失败")
|
||||
}
|
||||
return execution, startedAt, nil
|
||||
}
|
||||
|
||||
// finishCronExecution 执行定时任务主体并写回执行结果 // Logan.Li
|
||||
func finishCronExecution(db *gorm.DB, job model.CronJob, executionID uint, startedAt time.Time) {
|
||||
content := job.Content
|
||||
scriptType := job.Type
|
||||
if scriptType == "command" {
|
||||
@@ -36,20 +78,6 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
execution := model.TaskExecution{
|
||||
TaskType: "cron",
|
||||
TaskID: job.ID,
|
||||
TaskName: job.Name,
|
||||
Status: "running",
|
||||
TargetType: job.TargetType,
|
||||
Targets: job.Targets,
|
||||
StartedAt: &now,
|
||||
}
|
||||
if err := db.Create(&execution).Error; err != nil {
|
||||
return nil, fmt.Errorf("创建执行记录失败")
|
||||
}
|
||||
|
||||
var output string
|
||||
var exitCode int
|
||||
var duration int
|
||||
@@ -57,7 +85,7 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
|
||||
if job.TargetType != "" && job.TargetType != "local" && job.Targets != "" {
|
||||
remoteResults, remoteErr := ExecuteCronOnTargets(db, job, scriptType, content, job.Timeout)
|
||||
duration = int(time.Since(now).Milliseconds())
|
||||
duration = int(time.Since(startedAt).Milliseconds())
|
||||
if remoteErr != nil {
|
||||
output = fmt.Sprintf("远程执行调度失败: %v", remoteErr)
|
||||
exitCode = -1
|
||||
@@ -77,7 +105,7 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
if ok, reason := scriptrunner.CanRun(rt, scriptType); !ok {
|
||||
output = reason + "\n"
|
||||
exitCode = -1
|
||||
duration = int(time.Since(now).Milliseconds())
|
||||
duration = int(time.Since(startedAt).Milliseconds())
|
||||
status = "failed"
|
||||
} else {
|
||||
output, exitCode, duration = runLocal(scriptType, content, job.Timeout)
|
||||
@@ -88,7 +116,7 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
}
|
||||
|
||||
finishedAt := time.Now()
|
||||
db.Model(&execution).Updates(map[string]interface{}{
|
||||
db.Model(&model.TaskExecution{}).Where("id = ?", executionID).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"output": output,
|
||||
"duration": duration,
|
||||
@@ -96,7 +124,7 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
})
|
||||
|
||||
statsUpdates := map[string]interface{}{
|
||||
"last_run_at": now,
|
||||
"last_run_at": startedAt,
|
||||
"last_status": status,
|
||||
"run_count": job.RunCount + 1,
|
||||
}
|
||||
@@ -106,11 +134,5 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
statsUpdates["fail_count"] = job.FailCount + 1
|
||||
}
|
||||
db.Model(&job).Updates(statsUpdates)
|
||||
|
||||
execution.Output = output
|
||||
execution.Status = status
|
||||
execution.Duration = duration
|
||||
execution.FinishedAt = &finishedAt
|
||||
_ = exitCode
|
||||
return &execution, nil
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// K8s 集群列表统计增强 — 实时拉取资源数量,修复 Ingress 等与详情页不一致 // Logan.Li
|
||||
// K8s 集群列表统计增强 — 列表页只读本地快照,避免不可达集群阻塞全站 // Logan.Li
|
||||
package controller
|
||||
|
||||
import (
|
||||
@@ -7,28 +7,12 @@ import (
|
||||
"ezops/model"
|
||||
)
|
||||
|
||||
// enrichClustersForList 列表返回前刷新资源计数与节点均值使用率 // Logan.Li
|
||||
// enrichClustersForList 列表返回前补齐本地资源计数与节点均值使用率 // Logan.Li
|
||||
func (h *KubernetesHandler) enrichClustersForList(clusters []model.KubernetesCluster) {
|
||||
for i := range clusters {
|
||||
c := &clusters[i]
|
||||
h.fillClusterCountsFromDB(c)
|
||||
h.refreshClusterCountsLive(c)
|
||||
h.fillClusterUsageFromNodes(c)
|
||||
// 异步落库,避免列表阻塞过久
|
||||
snap := *c
|
||||
go func(cluster model.KubernetesCluster) {
|
||||
if err := h.db.Model(&model.KubernetesCluster{}).Where("id = ?", cluster.ID).Updates(map[string]interface{}{
|
||||
"node_count": cluster.NodeCount,
|
||||
"pod_count": cluster.PodCount,
|
||||
"service_count": cluster.ServiceCount,
|
||||
"deployment_count": cluster.DeploymentCount,
|
||||
"ingress_count": cluster.IngressCount,
|
||||
"cpu_usage": cluster.CPUUsage,
|
||||
"memory_usage": cluster.MemoryUsage,
|
||||
}).Error; err != nil {
|
||||
log.Printf("[K8s] enrich cluster %d stats persist error: %v", cluster.ID, err)
|
||||
}
|
||||
}(snap)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,4 +90,4 @@ func (h *KubernetesHandler) updateClusterUsageAfterSync(clusterID uint) {
|
||||
"memory_usage": cluster.MemoryUsage,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+199
-11
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -37,8 +38,52 @@ type RDPHandler struct {
|
||||
}
|
||||
|
||||
const rdpAuditMaxText = 500
|
||||
const rdpMaxWidth = 1024
|
||||
const rdpMaxHeight = 800
|
||||
|
||||
type sessionSecurityPolicy struct {
|
||||
ClipboardEnabled bool
|
||||
FileUploadEnabled bool
|
||||
FileDownloadEnabled bool
|
||||
SessionRecordEnabled bool
|
||||
}
|
||||
|
||||
func loadSessionSecurityPolicy(db *gorm.DB) sessionSecurityPolicy {
|
||||
policy := sessionSecurityPolicy{
|
||||
ClipboardEnabled: true,
|
||||
FileUploadEnabled: true,
|
||||
FileDownloadEnabled: true,
|
||||
SessionRecordEnabled: true,
|
||||
}
|
||||
var settings model.AuditSettings
|
||||
if err := db.First(&settings).Error; err != nil {
|
||||
return policy
|
||||
}
|
||||
policy.ClipboardEnabled = settings.ClipboardEnabled
|
||||
policy.FileUploadEnabled = settings.FileUploadEnabled
|
||||
policy.FileDownloadEnabled = settings.FileDownloadEnabled
|
||||
policy.SessionRecordEnabled = settings.SessionRecordEnabled
|
||||
return policy
|
||||
}
|
||||
|
||||
func rdpEventBlockedByPolicy(event rdpengine.ClientEvent, policy sessionSecurityPolicy) (bool, string) {
|
||||
switch event.Type {
|
||||
case "clipboard", "remote_copy":
|
||||
if !policy.ClipboardEnabled {
|
||||
return true, "会话策略已禁用剪贴板"
|
||||
}
|
||||
case "file_upload", "rdp_file_upload":
|
||||
if !policy.FileUploadEnabled {
|
||||
return true, "会话策略已禁用文件上传"
|
||||
}
|
||||
case "file_download":
|
||||
if !policy.FileDownloadEnabled {
|
||||
return true, "会话策略已禁用文件下载"
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
const rdpMaxWidth = 1920
|
||||
const rdpMaxHeight = 1200
|
||||
|
||||
func NewRDPHandler(db *gorm.DB, masterKey string, wsMgr *wshub.WSManager) *RDPHandler {
|
||||
return &RDPHandler{
|
||||
@@ -138,6 +183,7 @@ func (h *RDPHandler) Serve(c *gin.Context) {
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
username := middleware.GetUsername(c)
|
||||
policy := loadSessionSecurityPolicy(h.db)
|
||||
conn, info, err := h.wsMgr.Upgrade(c, "rdp", userID, username)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -183,7 +229,7 @@ func (h *RDPHandler) Serve(c *gin.Context) {
|
||||
}()
|
||||
|
||||
events := make(chan rdpengine.ClientEvent, 128)
|
||||
sink := &wsFrameSink{conn: conn, mgr: h.wsMgr, recorder: rdpengine.NewMemoryRecorder(500), live: liveHub, sessionID: session.ID}
|
||||
sink := &wsFrameSink{conn: conn, mgr: h.wsMgr, recorder: rdpengine.NewMemoryRecorder(500), live: liveHub, sessionID: session.ID, recordW: width, recordH: height}
|
||||
_ = sink.SendFrame(rdpengine.ServerFrame{Type: "session", Message: strconv.Itoa(int(session.ID)), Width: width, Height: height})
|
||||
engineDone := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -206,7 +252,10 @@ func (h *RDPHandler) Serve(c *gin.Context) {
|
||||
cancel()
|
||||
end := time.Now()
|
||||
duration := int(end.Sub(session.StartTime).Seconds())
|
||||
recordData := sink.recorder.JSON()
|
||||
recordData := ""
|
||||
if policy.SessionRecordEnabled {
|
||||
recordData = sink.recorder.JSON()
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"status": status,
|
||||
"duration": duration,
|
||||
@@ -224,6 +273,7 @@ func (h *RDPHandler) Serve(c *gin.Context) {
|
||||
defer audit.Flush()
|
||||
|
||||
readDone := make(chan struct{})
|
||||
modifiers := &rdpModifierState{}
|
||||
go func() {
|
||||
defer close(readDone)
|
||||
for {
|
||||
@@ -231,6 +281,11 @@ func (h *RDPHandler) Serve(c *gin.Context) {
|
||||
if err := conn.ReadJSON(&event); err != nil {
|
||||
return
|
||||
}
|
||||
if blocked, message := rdpEventBlockedByPolicy(event, policy); blocked {
|
||||
_ = sink.SendFrame(rdpengine.ServerFrame{Type: "status", Message: message})
|
||||
continue
|
||||
}
|
||||
modifiers.Apply(&event)
|
||||
audit.Record(event)
|
||||
enqueueRDPEvent(events, event)
|
||||
}
|
||||
@@ -286,6 +341,38 @@ func enqueueRDPEvent(events chan rdpengine.ClientEvent, event rdpengine.ClientEv
|
||||
}
|
||||
}
|
||||
|
||||
type rdpModifierState struct {
|
||||
ctrl bool
|
||||
alt bool
|
||||
shift bool
|
||||
meta bool
|
||||
}
|
||||
|
||||
// Apply 在服务端补齐修饰键状态,兼容旧前端未传 ctrlKey/altKey 的 RDP 按键事件。 // Logan.Li
|
||||
func (s *rdpModifierState) Apply(event *rdpengine.ClientEvent) {
|
||||
if event.Type != "key" {
|
||||
return
|
||||
}
|
||||
event.Ctrl = event.Ctrl || s.ctrl
|
||||
event.Alt = event.Alt || s.alt
|
||||
event.Shift = event.Shift || s.shift
|
||||
event.Meta = event.Meta || s.meta
|
||||
if event.Meta {
|
||||
event.Ctrl = true
|
||||
event.Meta = false
|
||||
}
|
||||
switch event.Code {
|
||||
case "ControlLeft", "ControlRight":
|
||||
s.ctrl = event.Down
|
||||
case "AltLeft", "AltRight":
|
||||
s.alt = event.Down
|
||||
case "ShiftLeft", "ShiftRight":
|
||||
s.shift = event.Down
|
||||
case "MetaLeft", "MetaRight":
|
||||
s.meta = event.Down
|
||||
}
|
||||
}
|
||||
|
||||
type rdpOperationRecorder struct {
|
||||
db *gorm.DB
|
||||
session model.RDPSession
|
||||
@@ -312,9 +399,15 @@ func (r *rdpOperationRecorder) Record(event rdpengine.ClientEvent) {
|
||||
case "clipboard":
|
||||
r.Flush()
|
||||
r.create("[RDP粘贴] " + truncateAuditText(event.Text, rdpAuditMaxText))
|
||||
case "remote_copy":
|
||||
r.Flush()
|
||||
r.create("[RDP复制] Ctrl+C")
|
||||
case "file_upload":
|
||||
r.Flush()
|
||||
r.create("[RDP上传] " + truncateAuditText(event.Text, rdpAuditMaxText))
|
||||
case "rdp_file_upload":
|
||||
r.Flush()
|
||||
r.create("[RDP上传到虚拟盘] " + truncateAuditText(event.Name, rdpAuditMaxText))
|
||||
case "file_download":
|
||||
r.Flush()
|
||||
r.create("[RDP下载] " + truncateAuditText(event.Text, rdpAuditMaxText))
|
||||
@@ -328,6 +421,15 @@ func (r *rdpOperationRecorder) recordKey(event rdpengine.ClientEvent, now time.T
|
||||
if event.Key == "Control" || event.Key == "Shift" || event.Key == "Alt" || event.Key == "Meta" {
|
||||
return
|
||||
}
|
||||
if event.Ctrl || event.Alt || event.Meta {
|
||||
r.Flush()
|
||||
if label, ok := rdpShortcutAuditLabel(event); ok {
|
||||
r.create(label)
|
||||
return
|
||||
}
|
||||
r.create("[RDP按键] " + rdpModifierLabel(event) + strings.ToUpper(event.Key))
|
||||
return
|
||||
}
|
||||
if event.Code == "Enter" || event.Code == "NumpadEnter" {
|
||||
if strings.TrimSpace(r.inputBuffer) != "" {
|
||||
r.Flush()
|
||||
@@ -370,6 +472,42 @@ func isAuditableRDPKey(key string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func rdpModifierLabel(event rdpengine.ClientEvent) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if event.Ctrl {
|
||||
parts = append(parts, "Ctrl")
|
||||
}
|
||||
if event.Alt {
|
||||
parts = append(parts, "Alt")
|
||||
}
|
||||
if event.Meta {
|
||||
parts = append(parts, "Meta")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts, "+") + "+"
|
||||
}
|
||||
|
||||
func rdpShortcutAuditLabel(event rdpengine.ClientEvent) (string, bool) {
|
||||
if !event.Ctrl || event.Alt || event.Meta {
|
||||
return "", false
|
||||
}
|
||||
key := strings.ToUpper(event.Key)
|
||||
switch key {
|
||||
case "A":
|
||||
return "[RDP全选] Ctrl+A", true
|
||||
case "C":
|
||||
return "[RDP复制] Ctrl+C", true
|
||||
case "V":
|
||||
return "[RDP粘贴快捷键] Ctrl+V", true
|
||||
case "X":
|
||||
return "[RDP剪切] Ctrl+X", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *rdpOperationRecorder) Flush() {
|
||||
if strings.TrimSpace(r.inputBuffer) == "" {
|
||||
r.inputBuffer = ""
|
||||
@@ -423,10 +561,16 @@ type wsFrameSink struct {
|
||||
bitmaps int
|
||||
lastLog time.Time
|
||||
lastRecord time.Time
|
||||
recordFB *image.RGBA
|
||||
recordW int
|
||||
recordH int
|
||||
seenScreen bool
|
||||
}
|
||||
|
||||
func (s *wsFrameSink) SendFrame(frame rdpengine.ServerFrame) error {
|
||||
s.recorder.Append(frame)
|
||||
if frame.Type != "bitmap" {
|
||||
s.recorder.Append(frame)
|
||||
}
|
||||
if s.live != nil {
|
||||
s.live.publish(frame)
|
||||
}
|
||||
@@ -451,20 +595,64 @@ func (s *wsFrameSink) snapshotFrame(frame rdpengine.ServerFrame) (rdpengine.Serv
|
||||
if frame.Type != "bitmap" || len(frame.RGBA) == 0 || frame.Width <= 0 || frame.Height <= 0 {
|
||||
return rdpengine.ServerFrame{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.recordW <= 0 || s.recordH <= 0 {
|
||||
s.recordW = frame.Width + frame.X
|
||||
s.recordH = frame.Height + frame.Y
|
||||
if s.recordW < 1 || s.recordH < 1 {
|
||||
return rdpengine.ServerFrame{}, false
|
||||
}
|
||||
}
|
||||
if s.recordFB == nil {
|
||||
s.recordFB = image.NewRGBA(image.Rect(0, 0, s.recordW, s.recordH))
|
||||
}
|
||||
rect := image.Rect(frame.X, frame.Y, frame.X+frame.Width, frame.Y+frame.Height).Intersect(s.recordFB.Bounds())
|
||||
if rect.Empty() {
|
||||
return rdpengine.ServerFrame{}, false
|
||||
}
|
||||
src := image.NewRGBA(image.Rect(0, 0, frame.Width, frame.Height))
|
||||
copy(src.Pix, frame.RGBA)
|
||||
draw.Draw(s.recordFB, rect, src, image.Point{X: rect.Min.X - frame.X, Y: rect.Min.Y - frame.Y}, draw.Src)
|
||||
if !s.seenScreen {
|
||||
if isMostlyBlackRGBA(s.recordFB, 0.985) {
|
||||
return rdpengine.ServerFrame{}, false
|
||||
}
|
||||
s.seenScreen = true
|
||||
}
|
||||
now := time.Now()
|
||||
if !s.lastRecord.IsZero() && now.Sub(s.lastRecord) < 500*time.Millisecond {
|
||||
return rdpengine.ServerFrame{}, false
|
||||
}
|
||||
s.lastRecord = now
|
||||
img := image.NewRGBA(image.Rect(0, 0, frame.Width, frame.Height))
|
||||
copy(img.Pix, frame.RGBA)
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
if err := png.Encode(&buf, s.recordFB); err != nil {
|
||||
return rdpengine.ServerFrame{}, false
|
||||
}
|
||||
frame.RGBA = nil
|
||||
frame.Image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
return frame, true
|
||||
return rdpengine.ServerFrame{
|
||||
Type: "bitmap",
|
||||
X: 0,
|
||||
Y: 0,
|
||||
Width: s.recordW,
|
||||
Height: s.recordH,
|
||||
Image: "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()),
|
||||
}, true
|
||||
}
|
||||
|
||||
// isMostlyBlackRGBA 判断录屏缓存是否仍是启动期黑屏,避免历史回放堆满无效黑帧。 // Logan.Li
|
||||
func isMostlyBlackRGBA(img *image.RGBA, threshold float64) bool {
|
||||
if img == nil || len(img.Pix) == 0 {
|
||||
return true
|
||||
}
|
||||
total := 0
|
||||
black := 0
|
||||
for i := 0; i+3 < len(img.Pix); i += 4 {
|
||||
total++
|
||||
if img.Pix[i] <= 4 && img.Pix[i+1] <= 4 && img.Pix[i+2] <= 4 {
|
||||
black++
|
||||
}
|
||||
}
|
||||
return total == 0 || float64(black)/float64(total) >= threshold
|
||||
}
|
||||
|
||||
func writeRDPFrame(mgr *wshub.WSManager, conn *websocket.Conn, frame rdpengine.ServerFrame) error {
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/crypto"
|
||||
"ezops/pkg/rdpfilestage"
|
||||
"ezops/pkg/winsmb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RDPFileHandler 通过 SMB 管理共享实现 RDP 虚拟 Download 盘文件传输 // Logan.Li
|
||||
type RDPFileHandler struct {
|
||||
db *gorm.DB
|
||||
crypt *crypto.SecretBox
|
||||
}
|
||||
|
||||
func NewRDPFileHandler(db *gorm.DB, masterKey string) *RDPFileHandler {
|
||||
return &RDPFileHandler{db: db, crypt: crypto.NewSecretBox(masterKey)}
|
||||
}
|
||||
|
||||
func (h *RDPFileHandler) loadAuth(serverID uint) (winsmb.Auth, error) {
|
||||
var server model.Server
|
||||
if err := h.db.First(&server, serverID).Error; err != nil {
|
||||
return winsmb.Auth{}, err
|
||||
}
|
||||
normalizeServerRemoteAccess(&server)
|
||||
if !isRDPServer(server) {
|
||||
return winsmb.Auth{}, errors.New("该资产未配置为 RDP/Windows")
|
||||
}
|
||||
cred, err := loadRDPCredential(h.crypt, server)
|
||||
if err != nil {
|
||||
return winsmb.Auth{}, err
|
||||
}
|
||||
return winsmb.Auth{
|
||||
Host: server.IP,
|
||||
Domain: cred.Domain,
|
||||
Username: cred.Username,
|
||||
Password: cred.Password,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *RDPFileHandler) ListFiles(c *gin.Context) {
|
||||
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
||||
return
|
||||
}
|
||||
auth, err := h.loadAuth(uint(serverID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
entries, err := winsmb.ListDownload(auth)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
type item struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
ModTime string `json:"mod_time"`
|
||||
}
|
||||
list := make([]item, 0, len(entries))
|
||||
for _, ent := range entries {
|
||||
if ent.IsDir {
|
||||
continue
|
||||
}
|
||||
list = append(list, item{
|
||||
Name: ent.Name,
|
||||
Size: ent.Size,
|
||||
IsDir: ent.IsDir,
|
||||
ModTime: ent.ModTime.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Data: gin.H{
|
||||
"root": winsmb.DownloadFullPath,
|
||||
"list": list,
|
||||
"total": len(list),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *RDPFileHandler) UploadFile(c *gin.Context) {
|
||||
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
||||
return
|
||||
}
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择文件"})
|
||||
return
|
||||
}
|
||||
if fileHeader.Size > 100<<20 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "单文件不能超过 100MB"})
|
||||
return
|
||||
}
|
||||
f, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "读取上传文件失败"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取上传文件失败"})
|
||||
return
|
||||
}
|
||||
auth, err := h.loadAuth(uint(serverID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := winsmb.Upload(auth, fileHeader.Filename, data); err != nil {
|
||||
c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Message: "上传成功",
|
||||
Data: gin.H{
|
||||
"name": fileHeader.Filename,
|
||||
"path": winsmb.DownloadFullPath + `\` + fileHeader.Filename,
|
||||
"size": fileHeader.Size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *RDPFileHandler) DownloadFile(c *gin.Context) {
|
||||
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
||||
return
|
||||
}
|
||||
name := c.Query("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少文件名"})
|
||||
return
|
||||
}
|
||||
auth, err := h.loadAuth(uint(serverID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
data, err := winsmb.Download(auth, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", "attachment; filename=\""+name+"\"")
|
||||
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||
}
|
||||
|
||||
// StageUpload SMB 不可用时:文件暂存 AIOps,返回可由 Windows 拉取的一次性 URL // Logan.Li
|
||||
func (h *RDPFileHandler) StageUpload(c *gin.Context) {
|
||||
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
||||
return
|
||||
}
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择文件"})
|
||||
return
|
||||
}
|
||||
if fileHeader.Size > 50<<20 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "回退模式单文件不能超过 50MB"})
|
||||
return
|
||||
}
|
||||
f, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "读取上传文件失败"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取上传文件失败"})
|
||||
return
|
||||
}
|
||||
if _, err := h.loadAuth(uint(serverID)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
token := rdpfilestage.Put(fileHeader.Filename, data, 30*time.Minute)
|
||||
base := strings.TrimRight(publicHTTPBase(c), "/")
|
||||
downloadURL := fmt.Sprintf("%s/api/v1/rdp-staged/%s", base, token)
|
||||
remotePath := winsmb.DownloadFullPath + `\` + fileHeader.Filename
|
||||
deployCmd := fmt.Sprintf(
|
||||
`powershell -NoProfile -WindowStyle Hidden -Command "$d='%s';if(!(Test-Path $d)){ni -ItemType Directory -Path $d -Force|Out-Null};iwr -UseBasicParsing -Uri '%s' -OutFile (Join-Path $d '%s')"`,
|
||||
strings.ReplaceAll(winsmb.DownloadFullPath, `'`, `''`),
|
||||
downloadURL,
|
||||
strings.ReplaceAll(fileHeader.Filename, `'`, `''`),
|
||||
)
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Data: gin.H{
|
||||
"token": token,
|
||||
"download_url": downloadURL,
|
||||
"remote_path": remotePath,
|
||||
"deploy_command": deployCmd,
|
||||
"mode": "stage",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ServeStaged 供 Windows 主机拉取暂存文件(token 即密钥)// Logan.Li
|
||||
func (h *RDPFileHandler) ServeStaged(c *gin.Context) {
|
||||
token := c.Param("token")
|
||||
item, ok := rdpfilestage.Get(token)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "链接已过期或不存在"})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", "attachment; filename=\""+item.Name+"\"")
|
||||
c.Data(http.StatusOK, "application/octet-stream", item.Data)
|
||||
}
|
||||
|
||||
func publicHTTPBase(c *gin.Context) string {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
host := c.Request.Host
|
||||
if fwd := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); fwd != "" {
|
||||
host = strings.Split(fwd, ",")[0]
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/rdp/filestage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ServeRDPStaged 供浏览器下载 RDPDR Download 目录捕获的暂存文件。// Logan.Li
|
||||
func ServeRDPStaged(c *gin.Context) {
|
||||
token := c.Param("token")
|
||||
item, ok := filestage.Get(token)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "链接已过期或不存在"})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", "attachment; filename=\""+item.Name+"\"")
|
||||
c.Data(http.StatusOK, "application/octet-stream", item.Data)
|
||||
}
|
||||
Reference in new issue
Block a user