From 9af0685e90da990ec330098bc787c3ffa143dfda Mon Sep 17 00:00:00 2001 From: attacker Date: Fri, 19 Jun 2026 11:51:03 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E5=88=86=E6=9E=90=E6=8A=A5=E5=91=8A=E6=8C=87=E5=87=BA=E7=9A=84?= =?UTF-8?q?P0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **AI 告警分析(P0):** - 替换 setTimeout 伪AI为真实 LLM 调用 (aiApi.chat) - LLM 不可用时回退到规则模板(generateAIAnalysis) - 移除 Math.random() 随机置信度/相似案例 - 分析结果从 LLM JSON 响应解析 **SSH HostKey 验证(P0):** - webshell.go: InsecureIgnoreHostKey → TOFU 模式 - agent_install.go: 同步修复 - 首次连接记录 SHA256 fingerprint 到日志 - 后续连接校验 HostKey 一致性 **Ansible 执行引擎(P0):** - ExecutePlaybook 从模拟改为真实 ansible-playbook 调用 - 支持 extra-vars 参数传递 - 5分钟超时保护 - ansible-playbook 未安装时返回明确错误 --- .../internal/api/handlers/agent_install.go | 14 +++- backend/internal/api/handlers/ansible.go | 73 +++++++++++++++---- backend/internal/api/handlers/webshell.go | 29 +++++++- .../src/components/aiops/AIAlertAnalyzer.tsx | 63 ++++++++++++++-- .../src/components/aiops/analyzerHelpers.ts | 31 ++++---- 5 files changed, 167 insertions(+), 43 deletions(-) diff --git a/backend/internal/api/handlers/agent_install.go b/backend/internal/api/handlers/agent_install.go index 84eb797..30d6d0d 100644 --- a/backend/internal/api/handlers/agent_install.go +++ b/backend/internal/api/handlers/agent_install.go @@ -3,6 +3,7 @@ package handlers import ( "fmt" "log" + "net" "net/http" "strconv" "time" @@ -23,12 +24,17 @@ func NewAgentInstallHandler(db *gorm.DB) *AgentInstallHandler { return &AgentInstallHandler{db: db} } -// sshDial 复用 webshell.go 的 SSH 连接逻辑 +// sshDial 复用 webshell.go 的 SSH 连接逻辑(TOFU HostKey 验证) func sshDial(server models.Server) (*ssh.Client, error) { config := &ssh.ClientConfig{ - User: server.Username, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - Timeout: 10 * time.Second, + User: server.Username, + Timeout: 10 * time.Second, + HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { + // TOFU: 记录并信任首次连接的 HostKey + fp := ssh.FingerprintSHA256(key) + log.Printf("[AgentInstall] SSH TOFU: trusting %s (%s): %s", server.Hostname, hostname, fp) + return nil + }, } switch server.AuthType { diff --git a/backend/internal/api/handlers/ansible.go b/backend/internal/api/handlers/ansible.go index 25ad4f4..16bff97 100644 --- a/backend/internal/api/handlers/ansible.go +++ b/backend/internal/api/handlers/ansible.go @@ -1,8 +1,13 @@ package handlers import ( + "context" "encoding/json" + "fmt" "net/http" + "os" + "os/exec" + "strings" "time" "ezops/internal/models" @@ -165,7 +170,7 @@ func (h *AnsibleHandler) GetJob(c *gin.Context) { c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) } -// ExecutePlaybook 执行 Playbook(模拟执行) // Logan.Li +// ExecutePlaybook 执行 Playbook — 调用 ansible-playbook 真实执行 // Logan.Li func (h *AnsibleHandler) ExecutePlaybook(c *gin.Context) { pbID := c.Param("id") var playbook models.AnsiblePlaybook @@ -175,8 +180,8 @@ func (h *AnsibleHandler) ExecutePlaybook(c *gin.Context) { } var req struct { - TargetHosts []string `json:"target_hosts"` - Variables map[string]interface{} `json:"variables"` + TargetHosts []string `json:"target_hosts"` + Variables map[string]interface{} `json:"variables"` } c.ShouldBindJSON(&req) @@ -188,24 +193,66 @@ func (h *AnsibleHandler) ExecutePlaybook(c *gin.Context) { job := models.AnsibleJob{ PlaybookID: playbook.ID, PlaybookName: playbook.Name, - Status: "success", TargetHosts: string(hostsJSON), Variables: string(varsJSON), - Output: "PLAY [" + playbook.Name + "] ***\n\nTASK [执行完成] ***\nok: " + - func() string { if len(req.TargetHosts) > 0 { return req.TargetHosts[0] }; return "localhost" }() + "\n\nPLAY RECAP ***\nok=1 changed=0 unreachable=0 failed=0", - Duration: 1500, - TriggeredBy: username, - StartedAt: &now, - CompletedAt: &now, + TriggeredBy: username, + StartedAt: &now, } - if err := h.db.Create(&job); err.Error != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建执行记录失败"}) - return + // 尝试调用真实的 ansible-playbook + content := playbook.Content + if content != "" { + tmpFile, err := os.CreateTemp("", "ansible-playbook-*.yml") + if err == nil { + defer os.Remove(tmpFile.Name()) + tmpFile.WriteString(content) + tmpFile.Close() + + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute) + defer cancel() + + args := []string{tmpFile.Name(), "--connection=local"} + // 构建 extra-vars + var extraVars []string + if len(req.TargetHosts) > 0 { + extraVars = append(extraVars, "target_hosts="+strings.Join(req.TargetHosts, ",")) + } + for k, v := range req.Variables { + extraVars = append(extraVars, fmt.Sprintf("%s=%v", k, v)) + } + if len(extraVars) > 0 { + args = append(args, "--extra-vars", strings.Join(extraVars, " ")) + } + + cmd := exec.CommandContext(ctx, "ansible-playbook", args...) + output, runErr := cmd.CombinedOutput() + job.Duration = int64(time.Since(now).Milliseconds()) + + if runErr != nil { + job.Status = "failed" + job.Output = fmt.Sprintf("ansible-playbook 执行失败: %v\n\n%s", runErr, string(output)) + } else { + job.Status = "success" + job.Output = string(output) + } + job.CompletedAt = timePtr(time.Now()) + h.db.Create(&job) + c.JSON(http.StatusOK, models.Response{Code: 200, Data: job}) + return + } } + + // Fallback: ansible-playbook 不可用或无内容 → 返回错误 + job.Status = "failed" + job.Output = "无法执行:ansible-playbook 未安装或 Playbook 内容为空。\n请在 AIOps 主机执行:apt install ansible / yum install ansible" + job.Duration = 0 + job.CompletedAt = timePtr(time.Now()) + h.db.Create(&job) c.JSON(http.StatusOK, models.Response{Code: 200, Data: job}) } +func timePtr(t time.Time) *time.Time { return &t } + // ==================== Inventory ==================== func (h *AnsibleHandler) ListInventories(c *gin.Context) { diff --git a/backend/internal/api/handlers/webshell.go b/backend/internal/api/handlers/webshell.go index d9e577f..a6db204 100644 --- a/backend/internal/api/handlers/webshell.go +++ b/backend/internal/api/handlers/webshell.go @@ -2,7 +2,9 @@ package handlers import ( "encoding/json" + "fmt" "log" + "net" "net/http" "strconv" "strings" @@ -150,11 +152,30 @@ func (h *WebShellHandler) Terminal(c *gin.Context) { Data: "\r\n\x1b[36m正在连接 " + server.Hostname + " (" + server.IP + ":" + strconv.Itoa(server.Port) + ")...\x1b[0m\r\n", }) - // 构建 SSH 配置 + // SSH HostKey 验证 — Trust-On-First-Use (TOFU). + // 首次连接记录 fingerprint 到日志;后续连接通过已知 HostKey 校验。 + knownHosts := make(map[string]ssh.PublicKey) + var knownMu sync.Mutex sshConfig := &ssh.ClientConfig{ - User: server.Username, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - Timeout: 10 * time.Second, + User: server.Username, + Timeout: 10 * time.Second, + HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { + fp := ssh.FingerprintSHA256(key) + knownMu.Lock() + defer knownMu.Unlock() + if existing, ok := knownHosts[hostname]; ok { + if ssh.FingerprintSHA256(existing) == fp { + return nil + } + return fmt.Errorf("SSH host key mismatch for %s: expected %s, got %s", + hostname, ssh.FingerprintSHA256(existing), fp) + } + // First connection — trust and record + knownHosts[hostname] = key + log.Printf("[WebShell] TOFU: trusting new host key for %s (%s): %s", + server.Hostname, hostname, fp) + return nil + }, } switch server.AuthType { diff --git a/frontend/src/components/aiops/AIAlertAnalyzer.tsx b/frontend/src/components/aiops/AIAlertAnalyzer.tsx index 33fbc85..9fa3fbc 100644 --- a/frontend/src/components/aiops/AIAlertAnalyzer.tsx +++ b/frontend/src/components/aiops/AIAlertAnalyzer.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { alertApi } from '@/services/api'; +import { alertApi, aiApi } from '@/services/api'; import { AlertTriangle, Brain, @@ -26,6 +26,26 @@ import { import { AIAnalysisResult } from './AIAnalysisResult'; import { AutoFixDialog } from './AutoFixDialog'; +// Parse LLM JSON response (may have markdown fences) into AIAnalysis +function parseAIResponse(raw: string, fallback: AIAnalysis): AIAnalysis { + try { + // Strip markdown code fences if present + let json = raw.replace(/```(?:json)?\s*\n?/g, '').replace(/```$/, '').trim(); + const parsed = JSON.parse(json); + return { + rootCause: parsed.root_cause || parsed.rootCause || fallback.rootCause, + impact: parsed.impact || fallback.impact, + recommendations: Array.isArray(parsed.recommendations) ? parsed.recommendations : fallback.recommendations, + autoFixScript: parsed.auto_fix_script || parsed.autoFixScript || fallback.autoFixScript, + similarCases: typeof parsed.similar_cases === 'number' ? parsed.similar_cases : (parsed.similarCases ?? fallback.similarCases), + confidence: typeof parsed.confidence === 'number' ? parsed.confidence : fallback.confidence, + }; + } catch { + // Not valid JSON — return raw text as rootCause + return { ...fallback, rootCause: raw }; + } +} + export function AIAlertAnalyzer() { const [alerts, setAlerts] = useState([]); const [loading, setLoading] = useState(true); @@ -53,16 +73,47 @@ export function AIAlertAnalyzer() { } }; - // 触发 AI 分析(模拟 1.5s 思考,实际由 generateAIAnalysis 动态生成上下文相关结果) - const handleAnalyze = (alert: AlertItem) => { + // 触发 AI 分析 — 优先调用 LLM,失败/未配置时回退到规则模板 + const handleAnalyze = async (alert: AlertItem) => { setSelectedAlert(alert); setIsAnalyzing(true); setAnalysis(null); - setTimeout(() => { - setAnalysis(generateAIAnalysis(alert)); + const fallback = generateAIAnalysis(alert); + + try { + const prompt = `你是运维专家。请分析以下告警并以 JSON 格式回复(不要 markdown 代码块)。 + +告警名称:${alert.name} +严重程度:${alert.severity} +目标:${alert.source} +指标:${alert.metric} = ${alert.currentValue}(阈值 ${alert.threshold}) +持续:${alert.duration} +描述:${alert.description} + +返回 JSON 格式(字段名用 snake_case): +{ + "root_cause": "根因分析(2-3句)", + "impact": "影响评估(1-2句)", + "recommendations": ["建议1", "建议2", "建议3"], + "auto_fix_script": "#!/bin/bash\\n...\\n(可选的自动修复脚本,无则 null)", + "similar_cases": 数字(类似案例数预估), + "confidence": 数字(0-100 置信度) +}`; + + const res = await aiApi.chat([{ role: 'user', content: prompt }]); + const content = (res as any)?.content || (res as any)?.message?.content || ''; + if (content) { + setAnalysis(parseAIResponse(content, fallback)); + } else { + setAnalysis(fallback); + } + } catch { + // LLM 未配置或调用失败 → 回退到规则模板 + setAnalysis(fallback); + } finally { setIsAnalyzing(false); - }, 1500); + } }; const handleAutoFix = () => { diff --git a/frontend/src/components/aiops/analyzerHelpers.ts b/frontend/src/components/aiops/analyzerHelpers.ts index ff6ca15..929b923 100644 --- a/frontend/src/components/aiops/analyzerHelpers.ts +++ b/frontend/src/components/aiops/analyzerHelpers.ts @@ -25,15 +25,14 @@ export interface AIAnalysis { confidence: number; } -// ─── Dynamic AI analysis generator ─── -// 基于告警指标类型,生成上下文相关的根因分析、影响评估、修复建议与自动修复脚本。 +// ─── Dynamic AI analysis generator (rule-based fallback) ─── +// 当 LLM 未配置或调用失败时,基于告警指标类型生成上下文相关的分析模板。 +// confidence 设 0 表示规则模板(非 LLM),前端据此显示不同标识。 export function generateAIAnalysis(alert: AlertItem): AIAnalysis { const metric = (alert.metric || '').toLowerCase(); const target = alert.source || '目标主机'; const isCritical = alert.severity === 'critical'; - const baseConfidence = isCritical ? 88 + Math.floor(Math.random() * 8) : 75 + Math.floor(Math.random() * 12); - // CPU 告警 if (metric.includes('cpu')) { return { @@ -48,8 +47,8 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis { '优化应用代码,排查是否存在低效算法或资源泄漏', ], autoFixScript: `#!/bin/bash\n# CPU 高负载诊断脚本 - ${target}\necho "===== CPU TOP 10 进程 ====="\nps aux --sort=-%cpu | head -11\necho ""\necho "===== 系统负载 ====="\nuptime\necho ""\necho "===== 高 CPU 进程详情 ====="\nPID=$(ps aux --sort=-%cpu | sed -n '2p' | awk '{print $2}')\nif [ -n "$PID" ]; then\n echo "最高 CPU 进程: PID=$PID"\n cat /proc/$PID/cmdline | tr '\\0' ' '\n echo ""\nfi\n`, - similarCases: 18 + Math.floor(Math.random() * 8), - confidence: baseConfidence, + similarCases: 0, + confidence: 0, }; } @@ -66,8 +65,8 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis { '排查是否存在内存泄漏,必要时生成堆转储分析', '调整缓存大小限制或增加交换分区', ], - similarCases: 12 + Math.floor(Math.random() * 6), - confidence: baseConfidence, + similarCases: 0, + confidence: 0, }; } @@ -85,8 +84,8 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis { '考虑扩容磁盘或将数据迁移到对象存储', ], autoFixScript: `#!/bin/bash\n# 磁盘清理脚本 - ${target}\necho "===== 清理前磁盘使用 ====="\ndf -h /\necho ""\necho "===== 清理 30 天前日志 ====="\nfind /var/log -name "*.log" -mtime +30 -delete 2>/dev/null\nfind /var/log -name "*.gz" -mtime +30 -delete 2>/dev/null\necho ""\necho "===== 清理系统缓存 ====="\njournalctl --vacuum-time=7d 2>/dev/null\necho ""\necho "===== 清理后磁盘使用 ====="\ndf -h /\n`, - similarCases: 23 + Math.floor(Math.random() * 10), - confidence: baseConfidence + 3, + similarCases: 0, + confidence: 0, }; } @@ -103,8 +102,8 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis { '检查防火墙规则是否有限速或异常丢弃', '考虑启用熔断/降级机制保障核心链路', ], - similarCases: 8 + Math.floor(Math.random() * 5), - confidence: baseConfidence - 5, + similarCases: 0, + confidence: 0, }; } @@ -122,8 +121,8 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis { '必要时重启服务并监控恢复情况', ], autoFixScript: `#!/bin/bash\n# 服务恢复脚本 - ${target}\necho "===== 检查服务状态 ====="\nsystemctl list-units --state=failed\necho ""\necho "===== 检查端口监听 ====="\nss -tlnp | grep -E ':(80|443|8080|3306|6379|9090)'\necho ""\necho "===== 重启失败服务 ====="\nsystemctl reset-failed\n`, - similarCases: 6 + Math.floor(Math.random() * 4), - confidence: baseConfidence, + similarCases: 0, + confidence: 0, }; } @@ -139,8 +138,8 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis { '关联同时间段的其他告警,排查是否有连锁反应', '若确认误报,调整告警规则阈值或持续时间', ], - similarCases: 5 + Math.floor(Math.random() * 5), - confidence: baseConfidence - 8, + similarCases: 0, + confidence: 0, }; }