Files
aiops/backend/controller/agent_install.go
T
Logan.Li 827d9a31cd fix: 正确识别 Agent 安装与审计失败
- 下载脚本失败时立即终止安装并提示 WAF 拦截
- Agent 服务未运行时不再计为安装成功
- 审计列表兼容后端 status 成功失败字段
2026-07-25 15:08:04 +08:00

783 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"context"
"fmt"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"ezops/model"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/ssh"
"gorm.io/gorm"
)
// AgentInstallHandler Agent 远程安装处理器 // Logan.Li
type AgentInstallHandler struct {
db *gorm.DB
}
func NewAgentInstallHandler(db *gorm.DB) *AgentInstallHandler {
return &AgentInstallHandler{db: db}
}
// auditCtx 快照请求上下文(用户身份 + 来源 IP),用于在异步 goroutine 中安全记录审计 // Logan.Li
// gin.Context 在请求返回后会被 gin 池复用,goroutine 内禁止直接访问 c,必须提前快照
type auditCtx struct {
userID uint
username string
ip string
userAgent string
}
// newAuditCtx 从 gin.Context 提取审计所需上下文快照 // Logan.Li
func newAuditCtx(c *gin.Context) auditCtx {
uid, _ := c.Get("userID")
uname, _ := c.Get("username")
id, _ := uid.(uint)
name, _ := uname.(string)
return auditCtx{
userID: id,
username: name,
ip: c.ClientIP(),
userAgent: c.Request.UserAgent(),
}
}
// truncateAuditText 截断过长输出,避免审计 Error 字段膨胀 // Logan.Li
func truncateAuditText(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) > n {
return s[:n] + "..."
}
return s
}
// recordAgentAudit 记录 Agent 安装/卸载/升级操作审计(含命令输出与验证结果)// Logan.Li
// status: 1=成功, 0=失败
func (h *AgentInstallHandler) recordAgentAudit(ac auditCtx, action, resource, resourceID string, status int, reqDetail, respDetail, errMsg string) {
uid := ac.userID
h.db.Create(&model.AuditLog{
UserID: &uid,
Username: ac.username,
IP: ac.ip,
UserAgent: ac.userAgent,
Module: "agent",
Action: action,
Resource: resource,
ResourceID: resourceID,
Request: truncateAuditText(reqDetail, 2048),
Response: truncateAuditText(respDetail, 8192),
Status: status,
Error: truncateAuditText(errMsg, 512),
})
}
// sshDial 复用 webshell.go 的 SSH 连接逻辑(TOFU HostKey 验证)
func sshDial(server model.Server) (*ssh.Client, error) {
config := &ssh.ClientConfig{
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 {
case "key":
signer, err := ssh.ParsePrivateKey([]byte(server.SSHKey))
if err != nil {
config.Auth = []ssh.AuthMethod{ssh.Password(server.Password)}
} else {
config.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
}
default:
config.Auth = []ssh.AuthMethod{ssh.Password(server.Password)}
}
addr := server.IP + ":" + strconv.Itoa(server.Port)
return ssh.Dial("tcp", addr, config)
}
// runSSHCommand 在远程主机执行命令并返回输出(默认 60s 超时,防止 sudo 密码等待导致挂死)// Logan.Li
func runSSHCommand(client *ssh.Client, cmd string) (string, error) {
return runSSHCommandWithTimeout(client, cmd, 60*time.Second)
}
// runSSHCommandWithTimeout 支持自定义超时
func runSSHCommandWithTimeout(client *ssh.Client, cmd string, timeout time.Duration) (string, error) {
session, err := client.NewSession()
if err != nil {
return "", err
}
defer session.Close()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
type result struct {
output []byte
err error
}
done := make(chan result, 1)
go func() {
out, err := session.CombinedOutput(cmd)
done <- result{output: out, err: err}
}()
select {
case <-ctx.Done():
session.Signal(ssh.SIGTERM)
return "", fmt.Errorf("命令执行超时(%s): %s", timeout, cmd)
case r := <-done:
return string(r.output), r.err
}
}
// installServerURL 解析 Agent 对外服务地址,优先使用前端提交的浏览器 origin。 // Logan.Li
func installServerURL(c *gin.Context, preferred string) (string, error) {
if strings.TrimSpace(preferred) != "" {
return validateInstallServerURL(preferred)
}
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
scheme = strings.TrimSpace(strings.Split(proto, ",")[0])
}
// 优先使用 X-Forwarded-Host(Nginx 暴露给客户端的真实域名),其次用 Host // Logan.Li
host := c.Request.Host
if fwd := c.GetHeader("X-Forwarded-Host"); fwd != "" {
host = strings.TrimSpace(strings.Split(fwd, ",")[0])
}
return validateInstallServerURL(fmt.Sprintf("%s://%s", scheme, host))
}
// validateInstallServerURL 只允许无路径的 HTTP(S) origin,防止命令参数注入。 // Logan.Li
func validateInstallServerURL(rawURL string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
return "", fmt.Errorf("Agent 服务地址必须是有效的 HTTP(S) 地址")
}
if parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("Agent 服务地址只能填写域名 origin,不允许包含路径、认证信息或查询参数")
}
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host), nil
}
// shellQuote 对 SSH 远程命令参数做单引号转义。 // Logan.Li
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}
// agentInstallCommand 先完整下载脚本再执行,确保 curl 失败能正确传递退出状态。 // Logan.Li
func agentInstallCommand(serverURL, token string) string {
scriptURL := shellQuote(serverURL + "/agent/install.sh")
serverArg := shellQuote(serverURL)
tokenArg := shellQuote(token)
return fmt.Sprintf(
`tmp=$(mktemp /tmp/aiops-agent-install.XXXXXX) && trap 'rm -f "$tmp"' EXIT && curl -fsSL -o "$tmp" %s && if [ "$(id -u)" -eq 0 ]; then bash "$tmp" --server %s --token %s; else sudo -n bash "$tmp" --server %s --token %s; fi`,
scriptURL, serverArg, tokenArg, serverArg, tokenArg,
)
}
// agentInstallErrorMessage 将常见安装失败转换为可操作的中文提示。 // Logan.Li
func agentInstallErrorMessage(serverURL, output string) string {
lowerOutput := strings.ToLower(output)
switch {
case strings.Contains(lowerOutput, "403") || strings.Contains(lowerOutput, "forbidden"):
return "域名代理或 WAF 拒绝 Agent 下载(HTTP 403),请为 /agent/* 关闭人机验证或加入机器访问白名单"
case strings.Contains(lowerOutput, "sudo: a password is required") || strings.Contains(lowerOutput, "需要密码"):
return "目标主机 SSH 用户没有免密 sudo 权限,请使用 root 账号或配置 passwordless sudo"
case strings.Contains(lowerOutput, "curl") && (strings.Contains(lowerOutput, "not found") || strings.Contains(lowerOutput, "未找到")):
return "目标主机未安装 curl"
case strings.Contains(lowerOutput, "下载失败") || strings.Contains(lowerOutput, "failed to connect") || strings.Contains(lowerOutput, "connection refused"):
return "目标主机无法下载安装脚本,请检查后端地址 " + serverURL + " 是否可达"
default:
return "Agent 安装命令执行失败"
}
}
// detectAgentVersion SSH 到目标主机读取已安装 Agent 的真实版本号 // Logan.Li
func detectAgentVersion(client *ssh.Client) string {
verOut, _ := runSSHCommand(client, "/usr/local/bin/aiops-agent version 2>/dev/null || /usr/local/bin/aiops-agent -v 2>/dev/null || echo ''")
verOut = strings.TrimSpace(verOut)
// 输出形如: AIOps Agent v2.1.0 (built ...) go=go1.24...
if strings.HasPrefix(verOut, "AIOps Agent ") {
fields := strings.Fields(verOut)
if len(fields) >= 3 {
return fields[2]
}
}
return ""
}
// InstallAgent 远程安装 Agent 到指定服务器
// POST /servers/:id/install-agent
// 支持传入临时凭证:首次连接成功后自动更新服务器认证凭据
func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var req struct {
Username string `json:"username"`
Password string `json:"password"`
SSHKey string `json:"ssh_key"`
AuthType string `json:"auth_type"`
Token string `json:"token"`
ServerURL string `json:"server_url"`
}
// 兼容旧调用方式:token 在 query,新方式在 body
c.ShouldBindJSON(&req)
if req.Token == "" {
req.Token = c.Query("token")
}
if req.AuthType == "" {
req.AuthType = "password"
}
var server model.Server
if err := h.db.First(&server, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "服务器不存在"})
return
}
if server.Status != "running" {
// 允许安装,不强制 running 状态
}
// 审计上下文 + 资源描述(同步流程,提前快照)// Logan.Li
ac := newAuditCtx(c)
resource := server.Hostname + " (" + server.IP + ")"
resourceID := strconv.Itoa(int(server.ID))
// 如果请求中提供了凭据,使用请求凭据覆盖服务器存储的凭据进行 SSH
effectiveServer := server
if req.Username != "" {
effectiveServer.Username = req.Username
}
if req.Password != "" {
effectiveServer.Password = req.Password
}
if req.SSHKey != "" {
effectiveServer.SSHKey = req.SSHKey
effectiveServer.AuthType = "key"
}
if req.AuthType != "" && req.AuthType != effectiveServer.AuthType {
effectiveServer.AuthType = req.AuthType
}
// 1. SSH 连接目标主机
client, err := sshDial(effectiveServer)
if err != nil {
log.Printf("[AgentInstall] SSH连接失败 %s: %v", server.IP, err)
h.recordAgentAudit(ac, "install", resource, resourceID, 0, resource, "", "SSH连接失败: "+formatSSHError(err))
c.JSON(http.StatusOK, model.Response{
Code: 500,
Message: "SSH 连接失败: " + formatSSHError(err),
})
return
}
defer client.Close()
// 首次连接成功 → 如果服务器之前未配置凭据,自动保存使用的凭据
if (server.Password == "" && server.SSHKey == "") || server.Username == "" {
updates := map[string]interface{}{
"username": effectiveServer.Username,
"auth_type": effectiveServer.AuthType,
}
if effectiveServer.Password != "" {
updates["password"] = effectiveServer.Password
}
if effectiveServer.SSHKey != "" {
updates["ssh_key"] = effectiveServer.SSHKey
}
if server.Username == "" {
updates["username"] = effectiveServer.Username
}
h.db.Model(&server).Updates(updates)
log.Printf("[AgentInstall] %s 首次连接成功,已自动关联凭证", server.Hostname)
}
// 2. 检测系统架构
archOut, _ := runSSHCommand(client, "uname -m")
arch := "amd64"
if len(archOut) > 0 && (contains(archOut, "aarch64") || contains(archOut, "arm64")) {
arch = "arm64"
}
// 3. 构造安装命令(使用 Agent API 服务地址)
serverURL, urlErr := installServerURL(c, req.ServerURL)
if urlErr != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: urlErr.Error()})
return
}
token := req.Token
if token == "" {
token = c.Query("token")
}
// 4. 先下载脚本再执行,curl/WAF 失败时不会被管道末端 bash 的退出码掩盖。 // Logan.Li
installCmd := agentInstallCommand(serverURL, token)
// 5. 执行安装
installOut, err := runSSHCommand(client, installCmd)
if err != nil {
msg := agentInstallErrorMessage(serverURL, installOut)
h.recordAgentAudit(ac, "install", resource, resourceID, 0,
fmt.Sprintf("server=%s arch=%s", serverURL, arch), installOut, msg)
c.JSON(http.StatusOK, model.Response{
Code: 500,
Message: msg,
Data: installOut,
})
return
}
// 5. 验证安装是否成功
verifyOut, _ := runSSHCommand(client, "systemctl is-active aiops-agent 2>/dev/null || echo 'not_found'")
installed := contains(verifyOut, "active") || contains(verifyOut, "running")
if !installed {
msg := "安装脚本已执行,但 Agent 服务未运行"
h.recordAgentAudit(ac, "install", resource, resourceID, 0,
fmt.Sprintf("server=%s arch=%s", serverURL, arch), installOut+"\n"+verifyOut, msg)
c.JSON(http.StatusOK, model.Response{Code: 500, Message: msg, Data: gin.H{"output": installOut, "verify": verifyOut}})
return
}
var agentVer string
if installed {
// 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本(等 Agent 首次上报真实数据)// Logan.Li
updates := map[string]interface{}{
"agent_status": "online",
"last_check_at": time.Now(),
}
agentVer = detectAgentVersion(client)
if agentVer != "" {
updates["agent_version"] = agentVer
}
h.db.Model(&server).Updates(updates)
}
// 审计:成功或"命令已执行但服务未运行"均记录,便于追溯 // Logan.Li
reqDetail := fmt.Sprintf("server=%s arch=%s", serverURL, arch)
respDetail := fmt.Sprintf("=== 安装输出 ===\n%s\n=== 服务状态 ===\n%s", installOut, verifyOut)
if agentVer != "" {
respDetail += "\n=== Agent版本 ===\n" + agentVer
}
if installed {
h.recordAgentAudit(ac, "install", resource, resourceID, 1, reqDetail, respDetail, "")
} else {
h.recordAgentAudit(ac, "install", resource, resourceID, 0, reqDetail, respDetail, "安装命令已执行但服务未检测到运行")
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: func() string {
if installed {
return "Agent 安装成功,已自动启动并设置开机自启"
}
return "安装命令已执行,但 Agent 服务未检测到运行(请查看日志排查)"
}(),
Data: gin.H{
"installed": installed,
"arch": arch,
"output": installOut,
"verify": verifyOut,
},
})
}
// installOne 安装单个服务器的 Agent(内部方法)
func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token string) (bool, string, string, error) {
client, err := sshDial(server)
if err != nil {
return false, "", "", fmt.Errorf("SSH连接失败: %w", err)
}
defer client.Close()
// 不强制 server.Status==running:只要 SSH 可达即可安装(DB 状态可能滞后于实际开机状态) // Logan.Li
installCmd := agentInstallCommand(serverURL, token)
installOut, err := runSSHCommand(client, installCmd)
if err != nil {
return false, installOut, "", err
}
verifyOut, _ := runSSHCommand(client, "systemctl is-active aiops-agent 2>/dev/null || echo 'not_found'")
installed := contains(verifyOut, "active") || contains(verifyOut, "running")
if !installed {
return false, installOut, verifyOut, fmt.Errorf("安装脚本已执行,但 Agent 服务未运行")
}
if installed {
// 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本 // Logan.Li
updates := map[string]interface{}{
"agent_status": "online",
"last_check_at": time.Now(),
}
if agentVersion := detectAgentVersion(client); agentVersion != "" {
updates["agent_version"] = agentVersion
}
h.db.Model(&server).Updates(updates)
}
return installed, installOut, verifyOut, nil
}
// BatchInstallAgent 批量安装 Agent(异步执行,立即返回)// Logan.Li
// POST /servers/install-agent
// 支持传入临时凭据:key 为服务器 ID,首次连接成功后自动保存到服务器记录
func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
var req struct {
ServerIDs []uint `json:"server_ids"`
ServerURL string `json:"server_url"`
Credentials map[string]struct {
Username string `json:"username"`
Password string `json:"password"`
SSHKey string `json:"ssh_key"`
AuthType string `json:"auth_type"`
} `json:"credentials"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要安装的服务器 ID 列表"})
return
}
serverURL, urlErr := installServerURL(c, req.ServerURL)
if urlErr != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: urlErr.Error()})
return
}
token := c.Query("token")
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
// 异步执行:立即返回,后台 goroutine 逐台安装
go func() {
successCount := 0
for _, sid := range req.ServerIDs {
var server model.Server
if err := h.db.First(&server, sid).Error; err != nil {
log.Printf("[BatchInstall] 服务器 %d 不存在,跳过", sid)
continue
}
// 如果请求提供了该服务器的临时凭据,覆盖使用(不立即写入 DB,由 installOne 首次连接成功后保存) // Logan.Li
effectiveServer := server
if cred, ok := req.Credentials[fmt.Sprintf("%d", sid)]; ok {
if cred.Username != "" {
effectiveServer.Username = cred.Username
}
if cred.Password != "" {
effectiveServer.Password = cred.Password
}
if cred.SSHKey != "" {
effectiveServer.SSHKey = cred.SSHKey
effectiveServer.AuthType = "key"
}
if cred.AuthType != "" {
effectiveServer.AuthType = cred.AuthType
}
}
installed, installOut, verifyOut, err := h.installOne(effectiveServer, serverURL, token)
res := server.Hostname + " (" + server.IP + ")"
rid := strconv.Itoa(int(server.ID))
reqDetail := fmt.Sprintf("server=%s host=%s", serverURL, res)
if err != nil {
errMessage := agentInstallErrorMessage(serverURL, installOut)
if errMessage == "Agent 安装命令执行失败" {
errMessage = err.Error()
}
log.Printf("[BatchInstall] %s (%s) 安装失败: %s", server.Hostname, server.IP, errMessage)
respDetail := fmt.Sprintf("=== 安装输出 ===\n%s\n=== 服务状态 ===\n%s", installOut, verifyOut)
h.recordAgentAudit(ac, "install", res, rid, 0, reqDetail, respDetail, truncateAuditText(errMessage, 500))
} else {
successCount++
respDetail := fmt.Sprintf("=== 安装输出 ===\n%s\n=== 服务状态 ===\n%s", installOut, verifyOut)
if installed {
log.Printf("[BatchInstall] %s Agent 安装成功", server.Hostname)
h.recordAgentAudit(ac, "install", res, rid, 1, reqDetail, respDetail, "")
} else {
log.Printf("[BatchInstall] %s Agent 安装命令已执行但服务未检测到运行", server.Hostname)
h.recordAgentAudit(ac, "install", res, rid, 0, reqDetail, respDetail, "服务未检测到运行")
}
}
}
log.Printf("[BatchInstall] 批量安装完成: %d/%d 成功", successCount, len(req.ServerIDs))
}()
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: fmt.Sprintf("已提交异步安装任务,共 %d 台主机,正在后台执行…", len(req.ServerIDs)),
Data: gin.H{"total": len(req.ServerIDs), "async": true},
})
}
// UninstallAgent 远程卸载 Agent
// POST /api/v1/agent/uninstall
func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) {
var req struct {
Hostname string `json:"hostname"`
ServerID *uint `json:"server_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误"})
return
}
var server model.Server
if req.ServerID != nil {
if err := h.db.First(&server, *req.ServerID).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "服务器不存在"})
return
}
} else if req.Hostname != "" {
if err := h.db.Where("hostname = ? OR ip = ?", req.Hostname, req.Hostname).First(&server).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "未找到服务器: " + req.Hostname})
return
}
} else {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供 hostname 或 server_id"})
return
}
ac := newAuditCtx(c) // Logan.Li
resource := server.Hostname + " (" + server.IP + ")"
resourceID := strconv.Itoa(int(server.ID))
// 无 SSH 凭据的主机(自动注册的)直接改本地状态;注意 SSH key 认证的主机 Password 为空但 SSHKey 非空,仍可远程卸载 // Logan.Li
if server.Username == "" || (server.Password == "" && server.SSHKey == "") {
h.db.Model(&server).Updates(map[string]interface{}{
"agent_status": "offline", "agent_version": "",
})
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, resource, "无SSH凭据,已标记离线", "")
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已标记为离线(该主机无SSH凭据,无法远程卸载,请手动执行 systemctl stop aiops-agent)"})
return
}
client, err := sshDial(server)
if err != nil {
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 0, resource, "", "SSH连接失败: "+formatSSHError(err))
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "SSH连接失败: " + formatSSHError(err)})
return
}
defer client.Close()
runSSHCommand(client, "systemctl stop aiops-agent 2>/dev/null; systemctl disable aiops-agent 2>/dev/null; rm -f /usr/local/bin/aiops-agent /etc/systemd/system/aiops-agent.service; systemctl daemon-reload 2>/dev/null")
h.db.Model(&server).Updates(map[string]interface{}{
"agent_status": "offline", "agent_version": "",
})
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, resource, "systemctl stop + rm binary", "")
c.JSON(http.StatusOK, model.Response{Code: 200, Message: fmt.Sprintf("已卸载 %s (%s) 的 Agent", server.Hostname, server.IP)})
}
// BatchUninstallAgent 批量卸载 Agent(异步执行,立即返回)// Logan.Li
// POST /api/v1/agent/batch-uninstall
func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) {
var req struct {
ServerIDs []uint `json:"server_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供服务器 ID 列表"})
return
}
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
// 异步执行:立即返回,后台逐台卸载
go func() {
successCount := 0
for _, id := range req.ServerIDs {
var server model.Server
if err := h.db.First(&server, id).Error; err != nil {
log.Printf("[BatchUninstall] 服务器 %d 不存在,跳过", id)
continue
}
res := server.Hostname + " (" + server.IP + ")"
rid := strconv.Itoa(int(server.ID))
if server.Username == "" || (server.Password == "" && server.SSHKey == "") {
h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""})
log.Printf("[BatchUninstall] %s 无SSH凭据,已标记离线", server.Hostname)
h.recordAgentAudit(ac, "uninstall", res, rid, 1, res, "无SSH凭据,已标记离线", "")
successCount++
continue
}
client, err := sshDial(server)
if err != nil {
log.Printf("[BatchUninstall] %s SSH失败: %v", server.Hostname, err)
h.recordAgentAudit(ac, "uninstall", res, rid, 0, res, "", "SSH连接失败: "+formatSSHError(err))
continue
}
runSSHCommand(client, "systemctl stop aiops-agent 2>/dev/null; systemctl disable aiops-agent 2>/dev/null; rm -f /usr/local/bin/aiops-agent /etc/systemd/system/aiops-agent.service; systemctl daemon-reload 2>/dev/null")
client.Close()
h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""})
log.Printf("[BatchUninstall] %s Agent 已卸载", server.Hostname)
h.recordAgentAudit(ac, "uninstall", res, rid, 1, res, "systemctl stop + rm binary", "")
successCount++
}
log.Printf("[BatchUninstall] 批量卸载完成: %d/%d 成功", successCount, len(req.ServerIDs))
}()
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: fmt.Sprintf("已提交异步卸载任务,共 %d 台主机,正在后台执行…", len(req.ServerIDs)),
Data: gin.H{"total": len(req.ServerIDs), "async": true},
})
}
// BatchUpgradeAgent 批量升级 Agent 到最新版本(SSH下载新二进制 + 重启服务)
// POST /api/v1/agent/batch-upgrade
func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
var req struct {
ServerIDs []uint `json:"server_ids"`
ServerURL string `json:"server_url"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要升级的服务器 ID 列表"})
return
}
serverURL, urlErr := installServerURL(c, req.ServerURL)
if urlErr != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: urlErr.Error()})
return
}
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
// 异步执行:立即返回,后台逐台升级
go func() {
successCount := 0
for _, sid := range req.ServerIDs {
var server model.Server
if err := h.db.First(&server, sid).Error; err != nil {
log.Printf("[BatchUpgrade] 服务器 %d 不存在,跳过", sid)
continue
}
if server.Username == "" || (server.Password == "" && server.SSHKey == "") {
log.Printf("[BatchUpgrade] %s 无SSH凭据,跳过", server.Hostname)
continue
}
res := server.Hostname + " (" + server.IP + ")"
rid := strconv.Itoa(int(server.ID))
// 检测架构
client, err := sshDial(server)
if err != nil {
log.Printf("[BatchUpgrade] %s SSH连接失败: %v", server.Hostname, err)
h.recordAgentAudit(ac, "upgrade", res, rid, 0, res, "", "SSH连接失败: "+formatSSHError(err))
continue
}
archOut, _ := runSSHCommand(client, "uname -m")
binaryFile := "aiops-agent-linux-amd64"
if len(archOut) > 0 && (contains(archOut, "aarch64") || contains(archOut, "arm64")) {
binaryFile = "aiops-agent-linux-arm64"
}
// 下载新二进制 + 获取版本号 + 重启服务
upgradeScript := fmt.Sprintf(
`curl -fsSL -o /tmp/aiops-agent %s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent; ver=$(/usr/local/bin/aiops-agent version 2>/dev/null | awk '/AIOps Agent/{print $3}'); echo "UPGRADE_OK ver=$ver"`,
shellQuote(serverURL+"/api/agent/bin/"+binaryFile),
)
out, err := runSSHCommand(client, upgradeScript)
client.Close()
if err != nil || !contains(out, "UPGRADE_OK") {
log.Printf("[BatchUpgrade] %s 升级失败: output=%s err=%v", server.Hostname, out, err)
h.recordAgentAudit(ac, "upgrade", res, rid, 0,
fmt.Sprintf("server=%s binary=%s", serverURL, binaryFile), out, "升级失败")
continue
}
// 从输出提取版本号
agentVersion := "unknown"
parts := strings.Split(out, "ver=")
if len(parts) > 1 {
agentVersion = strings.TrimSpace(parts[1])
}
h.db.Model(&server).Updates(map[string]interface{}{
"agent_version": agentVersion,
"last_check_at": time.Now(),
})
log.Printf("[BatchUpgrade] %s Agent 升级成功", server.Hostname)
h.recordAgentAudit(ac, "upgrade", res, rid, 1,
fmt.Sprintf("server=%s binary=%s", serverURL, binaryFile), out, "")
successCount++
}
log.Printf("[BatchUpgrade] 批量升级完成: %d/%d 成功", successCount, len(req.ServerIDs))
}()
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: fmt.Sprintf("已提交异步升级任务,共 %d 台主机,正在后台执行…", len(req.ServerIDs)),
Data: gin.H{"total": len(req.ServerIDs), "async": true},
})
}
// RemoteExec 直连 SSH 执行命令(即时返回,无轮询)
// POST /api/v1/agent/exec
func (h *AgentInstallHandler) RemoteExec(c *gin.Context) {
var req struct {
Hostname string `json:"hostname"`
Command string `json:"command"`
Timeout int `json:"timeout"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Hostname == "" || req.Command == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供 hostname 和 command"})
return
}
if req.Timeout <= 0 {
req.Timeout = 30
}
var server model.Server
if err := h.db.Where("hostname = ? OR ip = ?", req.Hostname, req.Hostname).First(&server).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "未找到服务器: " + req.Hostname})
return
}
client, err := sshDial(server)
if err != nil {
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "SSH连接失败: " + err.Error()})
return
}
defer client.Close()
type result struct {
out string
err error
}
done := make(chan result, 1)
go func() {
out, err := runSSHCommand(client, req.Command)
done <- result{out, err}
}()
select {
case r := <-done:
if r.err != nil {
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "执行完成", Data: gin.H{"output": r.out, "error": r.err.Error()}})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "执行成功", Data: gin.H{"output": r.out}})
case <-time.After(time.Duration(req.Timeout) * time.Second):
c.JSON(http.StatusOK, model.Response{Code: 408, Message: fmt.Sprintf("命令执行超时(%ds)", req.Timeout)})
}
}