fix: 修复架构分析报告指出的P0问题

**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 未安装时返回明确错误
This commit is contained in:
attacker committed 2026-06-19 11:51:03 +00:00
1 parent a3cd0d3910
commit 9af0685e90
5 files changed
+167 -43

No files matched your search

+10 -4
View File
@@ -3,6 +3,7 @@ package handlers
import ( import (
"fmt" "fmt"
"log" "log"
"net"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -23,12 +24,17 @@ func NewAgentInstallHandler(db *gorm.DB) *AgentInstallHandler {
return &AgentInstallHandler{db: db} return &AgentInstallHandler{db: db}
} }
// sshDial 复用 webshell.go 的 SSH 连接逻辑 // sshDial 复用 webshell.go 的 SSH 连接逻辑(TOFU HostKey 验证)
func sshDial(server models.Server) (*ssh.Client, error) { func sshDial(server models.Server) (*ssh.Client, error) {
config := &ssh.ClientConfig{ config := &ssh.ClientConfig{
User: server.Username, User: server.Username,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 10 * time.Second,
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 { switch server.AuthType {
+60 -13
View File
@@ -1,8 +1,13 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"os"
"os/exec"
"strings"
"time" "time"
"ezops/internal/models" "ezops/internal/models"
@@ -165,7 +170,7 @@ func (h *AnsibleHandler) GetJob(c *gin.Context) {
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item}) 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) { func (h *AnsibleHandler) ExecutePlaybook(c *gin.Context) {
pbID := c.Param("id") pbID := c.Param("id")
var playbook models.AnsiblePlaybook var playbook models.AnsiblePlaybook
@@ -175,8 +180,8 @@ func (h *AnsibleHandler) ExecutePlaybook(c *gin.Context) {
} }
var req struct { var req struct {
TargetHosts []string `json:"target_hosts"` TargetHosts []string `json:"target_hosts"`
Variables map[string]interface{} `json:"variables"` Variables map[string]interface{} `json:"variables"`
} }
c.ShouldBindJSON(&req) c.ShouldBindJSON(&req)
@@ -188,24 +193,66 @@ func (h *AnsibleHandler) ExecutePlaybook(c *gin.Context) {
job := models.AnsibleJob{ job := models.AnsibleJob{
PlaybookID: playbook.ID, PlaybookID: playbook.ID,
PlaybookName: playbook.Name, PlaybookName: playbook.Name,
Status: "success",
TargetHosts: string(hostsJSON), TargetHosts: string(hostsJSON),
Variables: string(varsJSON), Variables: string(varsJSON),
Output: "PLAY [" + playbook.Name + "] ***\n\nTASK [执行完成] ***\nok: " + TriggeredBy: username,
func() string { if len(req.TargetHosts) > 0 { return req.TargetHosts[0] }; return "localhost" }() + "\n\nPLAY RECAP ***\nok=1 changed=0 unreachable=0 failed=0", StartedAt: &now,
Duration: 1500,
TriggeredBy: username,
StartedAt: &now,
CompletedAt: &now,
} }
if err := h.db.Create(&job); err.Error != nil { // 尝试调用真实的 ansible-playbook
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建执行记录失败"}) content := playbook.Content
return 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}) c.JSON(http.StatusOK, models.Response{Code: 200, Data: job})
} }
func timePtr(t time.Time) *time.Time { return &t }
// ==================== Inventory ==================== // ==================== Inventory ====================
func (h *AnsibleHandler) ListInventories(c *gin.Context) { func (h *AnsibleHandler) ListInventories(c *gin.Context) {
+25 -4
View File
@@ -2,7 +2,9 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net"
"net/http" "net/http"
"strconv" "strconv"
"strings" "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", 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{ sshConfig := &ssh.ClientConfig{
User: server.Username, User: server.Username,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 10 * time.Second,
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 { switch server.AuthType {
@@ -3,7 +3,7 @@ import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { alertApi } from '@/services/api'; import { alertApi, aiApi } from '@/services/api';
import { import {
AlertTriangle, AlertTriangle,
Brain, Brain,
@@ -26,6 +26,26 @@ import {
import { AIAnalysisResult } from './AIAnalysisResult'; import { AIAnalysisResult } from './AIAnalysisResult';
import { AutoFixDialog } from './AutoFixDialog'; 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() { export function AIAlertAnalyzer() {
const [alerts, setAlerts] = useState<AlertItem[]>([]); const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -53,16 +73,47 @@ export function AIAlertAnalyzer() {
} }
}; };
// 触发 AI 分析(模拟 1.5s 思考,实际由 generateAIAnalysis 动态生成上下文相关结果) // 触发 AI 分析 — 优先调用 LLM,失败/未配置时回退到规则模板
const handleAnalyze = (alert: AlertItem) => { const handleAnalyze = async (alert: AlertItem) => {
setSelectedAlert(alert); setSelectedAlert(alert);
setIsAnalyzing(true); setIsAnalyzing(true);
setAnalysis(null); setAnalysis(null);
setTimeout(() => { const fallback = generateAIAnalysis(alert);
setAnalysis(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); setIsAnalyzing(false);
}, 1500); }
}; };
const handleAutoFix = () => { const handleAutoFix = () => {
@@ -25,15 +25,14 @@ export interface AIAnalysis {
confidence: number; confidence: number;
} }
// ─── Dynamic AI analysis generator ─── // ─── Dynamic AI analysis generator (rule-based fallback) ───
// 基于告警指标类型,生成上下文相关的根因分析、影响评估、修复建议与自动修复脚本。 // 当 LLM 未配置或调用失败时,基于告警指标类型生成上下文相关的分析模板。
// confidence 设 0 表示规则模板(非 LLM),前端据此显示不同标识。
export function generateAIAnalysis(alert: AlertItem): AIAnalysis { export function generateAIAnalysis(alert: AlertItem): AIAnalysis {
const metric = (alert.metric || '').toLowerCase(); const metric = (alert.metric || '').toLowerCase();
const target = alert.source || '目标主机'; const target = alert.source || '目标主机';
const isCritical = alert.severity === 'critical'; const isCritical = alert.severity === 'critical';
const baseConfidence = isCritical ? 88 + Math.floor(Math.random() * 8) : 75 + Math.floor(Math.random() * 12);
// CPU 告警 // CPU 告警
if (metric.includes('cpu')) { if (metric.includes('cpu')) {
return { 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`, 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), similarCases: 0,
confidence: baseConfidence, confidence: 0,
}; };
} }
@@ -66,8 +65,8 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis {
'排查是否存在内存泄漏,必要时生成堆转储分析', '排查是否存在内存泄漏,必要时生成堆转储分析',
'调整缓存大小限制或增加交换分区', '调整缓存大小限制或增加交换分区',
], ],
similarCases: 12 + Math.floor(Math.random() * 6), similarCases: 0,
confidence: baseConfidence, 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`, 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), similarCases: 0,
confidence: baseConfidence + 3, confidence: 0,
}; };
} }
@@ -103,8 +102,8 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis {
'检查防火墙规则是否有限速或异常丢弃', '检查防火墙规则是否有限速或异常丢弃',
'考虑启用熔断/降级机制保障核心链路', '考虑启用熔断/降级机制保障核心链路',
], ],
similarCases: 8 + Math.floor(Math.random() * 5), similarCases: 0,
confidence: baseConfidence - 5, 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`, 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), similarCases: 0,
confidence: baseConfidence, confidence: 0,
}; };
} }
@@ -139,8 +138,8 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis {
'关联同时间段的其他告警,排查是否有连锁反应', '关联同时间段的其他告警,排查是否有连锁反应',
'若确认误报,调整告警规则阈值或持续时间', '若确认误报,调整告警规则阈值或持续时间',
], ],
similarCases: 5 + Math.floor(Math.random() * 5), similarCases: 0,
confidence: baseConfidence - 8, confidence: 0,
}; };
} }