From 3b19ae6e9829b86f540a2fb2c1fb94d3e61ccb15 Mon Sep 17 00:00:00 2001 From: Logan Date: Tue, 30 Jun 2026 23:19:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(aiops):=20Agent=20CLI=20=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=20ssh-key=20=E4=B8=BB=E6=9C=BA=E6=B0=B8=E4=B9=85=E8=BD=AC?= =?UTF-8?q?=E5=9C=88=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildSSHConfig 放宽认证判定:SSHKey 非空即走密钥认证,不再强依赖 AuthType=="key", 兼容编辑表单/历史数据未正确设置 AuthType 的 ssh-key 主机(与 WebShell 行为对齐) - agent_cli WS 握手:Upgrade 提前到预检之前,预检失败经 writeWSCloseError 推具体 error 帧;写 error 后 sleep 120ms 再发 close,避免 TCP 提前关闭导致 error 帧丢失 - 前端 AgentCLI 连接兜底:新增 8s 连接超时 + onclose 区分"从未连上"(push 明确 原因提示)与"连上后断开",杜绝预检失败时"正在连接主机"永久转圈、看不到原因 - 引擎配置补传 ReasoningEnabled Co-Authored-By: Claude --- backend/controller/agent_cli.go | 47 +++++++++++++++------- backend/controller/remote_exec.go | 15 +++---- frontend/src/components/aiops/AgentCLI.tsx | 36 +++++++++++++++-- 3 files changed, 73 insertions(+), 25 deletions(-) diff --git a/backend/controller/agent_cli.go b/backend/controller/agent_cli.go index 128181e..84b9471 100644 --- a/backend/controller/agent_cli.go +++ b/backend/controller/agent_cli.go @@ -59,6 +59,18 @@ type wsControl struct { ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时 } +// writeWSCloseError 在 agentConn 建立前,直接通过 conn 推送一条 error 事件再关闭连接 // Logan.Li +// 用于 WS 升级后的预检失败(SSH 凭据/LLM 配置),让前端能拿到具体原因而非"一直转圈" +func writeWSCloseError(conn *websocket.Conn, msg string) { + _ = conn.WriteJSON(wsEvent{Type: "error", Message: msg}) + // 留出短暂窗口让浏览器接收 error 帧,再发送 close 帧:否则 close 帧紧随其后、叠加 + // defer conn.Close() 触发的 TCP 关闭,可能令 error 帧被丢弃,前端表现就是"正在连接主机" + // 永久转圈、看不到真实原因 // Logan.Li + time.Sleep(120 * time.Millisecond) + _ = conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, msg)) +} + // agentConn 单个 WS 连接的运行态 // Logan.Li type agentConn struct { conn *websocket.Conn @@ -84,24 +96,28 @@ func (h *AgentCLIHandler) Run(c *gin.Context) { c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "主机不存在"}) return } - sshCfg, err := buildSSHConfig(h.db, server) - if err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) - return - } - // LLM 配置 - var llmCfg model.LLMConfig - if err := h.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"}) - return - } + // 先升级 WebSocket:SSH 凭据 / LLM 配置预检放到升级之后,失败时通过 WS error 事件 + // 把真实原因推给前端,避免握手 400 导致前端"一直转圈"看不到原因 // Logan.Li conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return } defer conn.Close() + // SSH 凭据预检 + sshCfg, err := buildSSHConfig(h.db, server) + if err != nil { + writeWSCloseError(conn, "SSH 连接失败: "+err.Error()) + return + } + // LLM 配置预检 + var llmCfg model.LLMConfig + if err := h.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled { + writeWSCloseError(conn, "LLM 服务未配置或未启用,请先在「大模型设置」中配置") + return + } + ac := &agentConn{ conn: conn, db: h.db, userID: middleware.GetUserID(c), server: server, @@ -166,10 +182,11 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConf // 构建引擎 engine, err := agentcli.NewEngine(agentcli.EngineConfig{ LLM: llm.Config{ - Protocol: resolveProtocol(llmCfg.Provider), - Endpoint: resolveEndpoint(llmCfg.Provider, llmCfg.Endpoint), - APIKey: llmCfg.APIKey, Model: llmCfg.Model, - Temperature: llmCfg.Temperature, MaxTokens: llmCfg.MaxTokens, + Protocol: resolveProtocol(llmCfg.Provider), + Endpoint: resolveEndpoint(llmCfg.Provider, llmCfg.Endpoint), + APIKey: llmCfg.APIKey, Model: llmCfg.Model, + Temperature: llmCfg.Temperature, MaxTokens: llmCfg.MaxTokens, + ReasoningEnabled: llmCfg.ReasoningEnabled, }, SSH: sshCfg, SafetyMode: safety.ModeBalanced, diff --git a/backend/controller/remote_exec.go b/backend/controller/remote_exec.go index 50d91cd..bf85255 100644 --- a/backend/controller/remote_exec.go +++ b/backend/controller/remote_exec.go @@ -289,10 +289,15 @@ func buildSSHConfig(db *gorm.DB, server model.Server) (remoteexec.SSHConfig, err cfg.User = "root" } - // 优先使用 server 自带的认证信息 - if server.AuthType == "key" && server.SSHKey != "" { + // 优先 SSH 密钥:只要 server.SSHKey 有值就用密钥认证,不依赖 AuthType 严格等于 "key", + // 兼容历史数据/编辑表单未正确设置 AuthType 的 ssh-key 主机(与 WebShell 行为对齐)// Logan.Li + if server.SSHKey != "" { cfg.AuthType = remoteexec.AuthKey cfg.KeyData = server.SSHKey + // 同时带上 password 作为兜底:remoteexec 在密钥解析失败时会回退用密码 + if server.Password != "" { + cfg.Password = server.Password + } return cfg, nil } if server.Password != "" { @@ -301,11 +306,7 @@ func buildSSHConfig(db *gorm.DB, server model.Server) (remoteexec.SSHConfig, err return cfg, nil } - // 从凭证表查找关联的 SSH 凭据 - // Server 模型目前没有 CredentialID 字段,通过 tags 或 name 匹配 - // 未来可扩展 server.credential_id FK - - return cfg, fmt.Errorf("服务器 %s 未配置 SSH 认证(密码或密钥)", server.Hostname) + return cfg, fmt.Errorf("服务器 %s 未配置 SSH 认证(密码或密钥),请到服务器详情关联 SSH 凭据", server.Hostname) } // ==================== 定时任务远程执行(对接 CronJob target_type) ==================== diff --git a/frontend/src/components/aiops/AgentCLI.tsx b/frontend/src/components/aiops/AgentCLI.tsx index c799b0a..bb29565 100644 --- a/frontend/src/components/aiops/AgentCLI.tsx +++ b/frontend/src/components/aiops/AgentCLI.tsx @@ -90,15 +90,45 @@ export default function AgentCLI() { if (!serverId) return; setItems([]); setRound(0); setTokens(0); setRunning(false); + setConnected(false); const ws = new WebSocket(buildAgentCliWsUrl(serverId)); wsRef.current = ws; - ws.onopen = () => setConnected(true); - ws.onclose = () => { setConnected(false); setRunning(false); }; + let everConnected = false; // 是否曾握手成功:区分"预检失败被拒"与"连上后断开" // Logan.Li + let active = true; // 本轮连接是否仍活跃:切换主机/卸载后置 false,避免脏消息 // Logan.Li + + // 连接超时兜底:握手迟迟不成功则判定失败,避免"正在连接主机"永久转圈 // Logan.Li + const connectTimer = window.setTimeout(() => { + if (active && !everConnected && ws.readyState !== WebSocket.OPEN) { + push({ kind: 'error', content: '连接主机超时:后端长时间未响应,请检查后端服务状态、SSH 凭据与大模型配置后重试。' }); + try { ws.close(); } catch { /* noop */ } + } + }, 8000); + + ws.onopen = () => { everConnected = true; setConnected(true); }; + ws.onclose = () => { + if (!active) return; // 切换主机触发的关闭,忽略,不写脏消息 + window.clearTimeout(connectTimer); + setConnected(false); + setRunning(false); + // 从未握上手就被对端关闭(SSH 凭据预检失败 / 大模型未启用 / 网络中断): + // 此时后端的 error 帧可能因 TCP 提前关闭而丢失,onclose 收不到具体原因。 + // 这里兜底给出明确提示,杜绝"正在连接主机"永久转圈、用户看不到原因 // Logan.Li + if (!everConnected) { + push({ kind: 'error', content: '主机连接被拒绝:常见为该主机未关联可用的 SSH 凭据(密码或密钥),或大模型未启用。请到「服务器管理」确认该主机凭据、到「大模型设置」确认已启用模型后重试。' }); + } else { + push({ kind: 'info', content: '连接已关闭' }); + } + }; ws.onerror = () => { setConnected(false); }; ws.onmessage = (e) => { try { handleEvent(JSON.parse(e.data)); } catch { /* 忽略非 JSON */ } }; - return () => { ws.close(); wsRef.current = null; }; + return () => { + active = false; + window.clearTimeout(connectTimer); + ws.close(); + wsRef.current = null; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [serverId]);