783 lines
28 KiB
Go
783 lines
28 KiB
Go
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)})
|
||
}
|
||
}
|