Files
aiops/backend/controller/agent_install.go
T
LoganandClaude f820f0271f feat(remote-exec): 新增远程执行页(执行/上传) + 移除ansible命名 + 主机监控bug修复
远程执行页 RemoteExecPage:关联主机多选 + 批量执行命令 + 批量上传文件(Go-native SSH),高危操作 useConfirm 确认;后端新增 multipart /remote-exec/upload-file 接口(接收文件→SCP 转发到多台主机)。

移除 ansible 命名:菜单 /ansible→/remote、组 id automation→task、子项 id ansible→remote-exec、种子显示名 Ansible→远程执行;删除 dead code ansibleApi。

主机监控 bug:installOne 不再强制 server.Status==running(默认 stopped 导致批量安装必失败);UninstallAgent 凭据判断修正(SSH key 主机 Password 为空不应误判为无凭据而跳过远程卸载)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-28 23:29:32 +08:00

513 lines
17 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 (
"fmt"
"log"
"net"
"net/http"
"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}
}
// 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 在远程主机执行命令并返回输出
func runSSHCommand(client *ssh.Client, cmd string) (string, error) {
session, err := client.NewSession()
if err != nil {
return "", err
}
defer session.Close()
output, err := session.CombinedOutput(cmd)
return string(output), err
}
// 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"`
}
// 兼容旧调用方式: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 状态
}
// 如果请求中提供了凭据,使用请求凭据覆盖服务器存储的凭据进行 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)
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 := fmt.Sprintf("http://%s", c.Request.Host)
token := c.Query("token")
installCmd := fmt.Sprintf(
`curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token %s`,
serverURL, serverURL, token,
)
// 4. 执行安装
installOut, err := runSSHCommand(client, installCmd)
if err != nil {
c.JSON(http.StatusOK, model.Response{
Code: 500,
Message: "安装命令执行失败(可能需要 sudo 权限或 curl 未安装)",
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 {
// 更新服务器 Agent 状态(版本号:安装成功后由 Agent 下一次上报自动更新)
h.db.Model(&server).Updates(map[string]interface{}{
"agent_status": "online",
"agent_version": "v0.1.0",
"last_check_at": time.Now(),
})
}
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 := fmt.Sprintf(
`curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token %s`,
serverURL, 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 {
h.db.Model(&server).Updates(map[string]interface{}{
"agent_status": "online",
"agent_version": "v0.1.0",
"last_check_at": time.Now(),
})
}
return installed, installOut, verifyOut, nil
}
// BatchInstallAgent 批量安装 Agent(异步执行,立即返回)// Logan.Li
// POST /servers/install-agent
func (h *AgentInstallHandler) BatchInstallAgent(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
}
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
token := c.Query("token")
// 异步执行:立即返回,后台 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
}
installed, _, _, err := h.installOne(server, serverURL, token)
if err != nil {
log.Printf("[BatchInstall] %s (%s) 安装失败: %v", server.Hostname, server.IP, err)
} else {
successCount++
if installed {
log.Printf("[BatchInstall] %s Agent 安装成功", server.Hostname)
} else {
log.Printf("[BatchInstall] %s Agent 安装命令已执行但服务未检测到运行", server.Hostname)
}
}
}
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
}
// 无 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": "",
})
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已标记为离线(该主机无SSH凭据,无法远程卸载,请手动执行 systemctl stop aiops-agent)"})
return
}
client, err := sshDial(server)
if err != nil {
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": "",
})
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
}
// 异步执行:立即返回,后台逐台卸载
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
}
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)
successCount++
continue
}
client, err := sshDial(server)
if err != nil {
log.Printf("[BatchUninstall] %s SSH失败: %v", server.Hostname, 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)
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"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要升级的服务器 ID 列表"})
return
}
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
// 异步执行:立即返回,后台逐台升级
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
}
// 检测架构
client, err := sshDial(server)
if err != nil {
log.Printf("[BatchUpgrade] %s SSH连接失败: %v", server.Hostname, 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/api/agent/bin/%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 output 2>/dev/null | grep 'agent.version' | awk -F'[|[|]' '{print $2}' | tr -d ' '); echo "UPGRADE_OK ver=$ver"`,
serverURL, 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)
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)
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)})
}
}