- 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 <noreply@anthropic.com>
367 lines
11 KiB
Go
367 lines
11 KiB
Go
package controller
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"ezops/model"
|
||
"ezops/pkg/remoteexec"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// RemoteExecHandler 远程执行处理器 — Go 原生 SSH 批量执行,替代 Ansible // Logan.Li
|
||
type RemoteExecHandler struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
// NewRemoteExecHandler 创建远程执行处理器
|
||
func NewRemoteExecHandler(db *gorm.DB) *RemoteExecHandler {
|
||
return &RemoteExecHandler{db: db}
|
||
}
|
||
|
||
// ==================== 远程命令执行 ====================
|
||
|
||
// BatchExecRequest 批量执行请求
|
||
type BatchExecRequest struct {
|
||
ServerIDs []uint `json:"server_ids" binding:"required"` // 目标服务器 ID 列表
|
||
Command string `json:"command" binding:"required"` // 要执行的命令
|
||
Timeout int `json:"timeout"` // 超时(秒),默认 300
|
||
}
|
||
|
||
// BatchExec 批量远程执行命令 // Logan.Li
|
||
// POST /api/v1/remote-exec/execute
|
||
func (h *RemoteExecHandler) BatchExec(c *gin.Context) {
|
||
var req BatchExecRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||
return
|
||
}
|
||
|
||
// 查询目标服务器
|
||
var servers []model.Server
|
||
if err := h.db.Where("id IN ?", req.ServerIDs).Find(&servers).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "查询服务器失败"})
|
||
return
|
||
}
|
||
|
||
if len(servers) == 0 {
|
||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "未找到目标服务器"})
|
||
return
|
||
}
|
||
|
||
// 构建 SSH 配置
|
||
configs := make([]remoteexec.SSHConfig, 0, len(servers))
|
||
for _, s := range servers {
|
||
cfg, err := buildSSHConfig(h.db, s)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, model.Response{
|
||
Code: 400, Message: fmt.Sprintf("服务器 %s 认证配置错误: %v", s.Hostname, err),
|
||
})
|
||
return
|
||
}
|
||
configs = append(configs, cfg)
|
||
}
|
||
|
||
timeout := req.Timeout
|
||
if timeout <= 0 {
|
||
timeout = 300
|
||
}
|
||
|
||
// 并发执行
|
||
results := remoteexec.BatchExec(configs, req.Command, time.Duration(timeout)*time.Second, 10)
|
||
|
||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: results})
|
||
}
|
||
|
||
// ==================== 文件批量上传 ====================
|
||
|
||
// BatchUploadRequest 批量上传请求
|
||
type BatchUploadRequest struct {
|
||
ServerIDs []uint `json:"server_ids" binding:"required"` // 目标服务器 ID 列表
|
||
LocalPath string `json:"local_path" binding:"required"` // AIOps 主机上的文件路径
|
||
RemotePath string `json:"remote_path" binding:"required"` // 远程目标路径
|
||
}
|
||
|
||
// BatchUpload 批量上传文件到远程服务器 // Logan.Li
|
||
// POST /api/v1/remote-exec/upload
|
||
func (h *RemoteExecHandler) BatchUpload(c *gin.Context) {
|
||
var req BatchUploadRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||
return
|
||
}
|
||
|
||
if !strings.HasPrefix(req.LocalPath, "/") {
|
||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "local_path 必须是绝对路径"})
|
||
return
|
||
}
|
||
|
||
// 查询目标服务器
|
||
var servers []model.Server
|
||
if err := h.db.Where("id IN ?", req.ServerIDs).Find(&servers).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "查询服务器失败"})
|
||
return
|
||
}
|
||
|
||
if len(servers) == 0 {
|
||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "未找到目标服务器"})
|
||
return
|
||
}
|
||
|
||
// 构建 SSH 配置
|
||
configs := make([]remoteexec.SSHConfig, 0, len(servers))
|
||
for _, s := range servers {
|
||
cfg, err := buildSSHConfig(h.db, s)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, model.Response{
|
||
Code: 400, Message: fmt.Sprintf("服务器 %s 认证配置错误: %v", s.Hostname, err),
|
||
})
|
||
return
|
||
}
|
||
configs = append(configs, cfg)
|
||
}
|
||
|
||
// 并发上传
|
||
results := remoteexec.BatchUpload(configs, req.LocalPath, req.RemotePath, 5)
|
||
|
||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: results})
|
||
}
|
||
|
||
// UploadFile 前端上传文件到多台远程主机(multipart:file + server_ids + remote_path) // Logan.Li
|
||
// 后端接收文件到临时路径,经 SSH SCP 转发到目标主机,完成后清理临时文件。
|
||
// POST /api/v1/remote-exec/upload-file
|
||
func (h *RemoteExecHandler) UploadFile(c *gin.Context) {
|
||
file, err := c.FormFile("file")
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择要上传的文件"})
|
||
return
|
||
}
|
||
remotePath := strings.TrimSpace(c.PostForm("remote_path"))
|
||
if remotePath == "" {
|
||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请填写远程目标路径(含文件名)"})
|
||
return
|
||
}
|
||
var serverIDs []uint
|
||
if err := json.Unmarshal([]byte(c.PostForm("server_ids")), &serverIDs); err != nil || len(serverIDs) == 0 {
|
||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择目标主机"})
|
||
return
|
||
}
|
||
|
||
// 1. 保存上传文件到临时路径
|
||
tmp, err := os.CreateTemp("", "aiops-upload-*")
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建临时文件失败"})
|
||
return
|
||
}
|
||
tmpPath := tmp.Name()
|
||
tmp.Close()
|
||
defer os.Remove(tmpPath)
|
||
if err := c.SaveUploadedFile(file, tmpPath); err != nil {
|
||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存上传文件失败: " + err.Error()})
|
||
return
|
||
}
|
||
|
||
// 2. 查服务器 + 构建 SSH 配置
|
||
var servers []model.Server
|
||
if err := h.db.Where("id IN ?", serverIDs).Find(&servers).Error; err != nil || len(servers) == 0 {
|
||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "未找到目标服务器"})
|
||
return
|
||
}
|
||
configs := make([]remoteexec.SSHConfig, 0, len(servers))
|
||
for _, s := range servers {
|
||
cfg, err := buildSSHConfig(h.db, s)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: fmt.Sprintf("服务器 %s 认证配置错误: %v", s.Hostname, err)})
|
||
return
|
||
}
|
||
configs = append(configs, cfg)
|
||
}
|
||
|
||
// 3. 并发 SCP 上传到所有目标主机
|
||
results := remoteexec.BatchUpload(configs, tmpPath, remotePath, 5)
|
||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: results})
|
||
}
|
||
|
||
// ==================== 主机匹配 ====================
|
||
|
||
// MatchHosts 预览匹配的主机列表(从资产服务器表查询)// Logan.Li
|
||
// GET /api/v1/remote-exec/hosts?pattern=web*&match_type=prefix
|
||
func (h *RemoteExecHandler) MatchHosts(c *gin.Context) {
|
||
pattern := c.Query("pattern")
|
||
matchType := c.DefaultQuery("match_type", "contains")
|
||
|
||
query := h.db.Model(&model.Server{})
|
||
|
||
if pattern != "" {
|
||
switch matchType {
|
||
case "prefix":
|
||
query = query.Where("hostname LIKE ? OR ip LIKE ?", pattern+"%", pattern+"%")
|
||
case "regex":
|
||
query = query.Where("hostname REGEXP ? OR ip REGEXP ?", pattern, pattern)
|
||
default: // contains
|
||
query = query.Where("hostname LIKE ? OR ip LIKE ? OR tags LIKE ?",
|
||
"%"+pattern+"%", "%"+pattern+"%", "%"+pattern+"%")
|
||
}
|
||
}
|
||
|
||
var total int64
|
||
query.Count(&total)
|
||
|
||
var items []model.Server
|
||
query.Order("hostname").Find(&items)
|
||
|
||
// 补充 HasAuth 字段
|
||
for i := range items {
|
||
items[i].HasAuth = items[i].Password != "" || items[i].SSHKey != ""
|
||
}
|
||
|
||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{List: items, Total: total}})
|
||
}
|
||
|
||
// ==================== 单机命令执行 ====================
|
||
|
||
// ExecOnServer 在指定服务器上执行命令并返回结果
|
||
// POST /api/v1/remote-exec/server/:id/exec
|
||
func (h *RemoteExecHandler) ExecOnServer(c *gin.Context) {
|
||
id, err := strconv.Atoi(c.Param("id"))
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器 ID"})
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Command string `json:"command" binding:"required"`
|
||
Timeout int `json:"timeout"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||
return
|
||
}
|
||
|
||
var server model.Server
|
||
if err := h.db.First(&server, id).Error; err != nil {
|
||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "服务器不存在"})
|
||
return
|
||
}
|
||
|
||
cfg, err := buildSSHConfig(h.db, server)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "认证配置错误: " + err.Error()})
|
||
return
|
||
}
|
||
|
||
timeout := req.Timeout
|
||
if timeout <= 0 {
|
||
timeout = 300
|
||
}
|
||
|
||
result, err := remoteexec.Exec(cfg, req.Command, time.Duration(timeout)*time.Second)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error(), Data: result})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: result})
|
||
}
|
||
|
||
// ==================== 辅助函数 ====================
|
||
|
||
// buildSSHConfig 从 Server 模型构建 SSHConfig。
|
||
// 优先使用 server 自带的 Password/SSHKey 字段,
|
||
// 如果 server 关联了 credential_id,则从凭证表解密获取。
|
||
func buildSSHConfig(db *gorm.DB, server model.Server) (remoteexec.SSHConfig, error) {
|
||
cfg := remoteexec.SSHConfig{
|
||
Host: server.IP,
|
||
Port: server.Port,
|
||
User: server.Username,
|
||
}
|
||
if cfg.Port == 0 {
|
||
cfg.Port = 22
|
||
}
|
||
if cfg.User == "" {
|
||
cfg.User = "root"
|
||
}
|
||
|
||
// 优先 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 != "" {
|
||
cfg.AuthType = remoteexec.AuthPassword
|
||
cfg.Password = server.Password
|
||
return cfg, nil
|
||
}
|
||
|
||
return cfg, fmt.Errorf("服务器 %s 未配置 SSH 认证(密码或密钥),请到服务器详情关联 SSH 凭据", server.Hostname)
|
||
}
|
||
|
||
// ==================== 定时任务远程执行(对接 CronJob target_type) ====================
|
||
|
||
// ExecuteCronOnTargets 根据 CronJob 的 target_type 在目标主机上执行脚本。
|
||
// 由 automation_cron.go 调用的公共函数。
|
||
func ExecuteCronOnTargets(db *gorm.DB, job model.CronJob, scriptType, content string, timeoutSec int) ([]remoteexec.ExecResult, error) {
|
||
// 解析目标服务器
|
||
var targetIDs []uint
|
||
if job.Targets != "" {
|
||
json.Unmarshal([]byte(job.Targets), &targetIDs)
|
||
}
|
||
|
||
if len(targetIDs) == 0 {
|
||
return nil, fmt.Errorf("cron job %s has no targets", job.Name)
|
||
}
|
||
|
||
// 查询服务器
|
||
var servers []model.Server
|
||
switch job.TargetType {
|
||
case "server":
|
||
db.Where("id IN ?", targetIDs).Find(&servers)
|
||
case "group":
|
||
db.Where("id IN ?", targetIDs).Find(&servers)
|
||
case "tag":
|
||
// 标签匹配 — 通过 tags JSON 字段模糊查询
|
||
tagStr := fmt.Sprintf("%v", targetIDs)
|
||
db.Where("tags LIKE ?", "%"+tagStr+"%").Find(&servers)
|
||
default:
|
||
db.Where("id IN ?", targetIDs).Find(&servers)
|
||
}
|
||
|
||
if len(servers) == 0 {
|
||
return nil, fmt.Errorf("no target servers found for cron job %s", job.Name)
|
||
}
|
||
|
||
// 构建 SSH 配置
|
||
configs := make([]remoteexec.SSHConfig, 0, len(servers))
|
||
for _, s := range servers {
|
||
cfg, err := buildSSHConfig(db, s)
|
||
if err != nil {
|
||
continue // 跳过认证失败的主机
|
||
}
|
||
configs = append(configs, cfg)
|
||
}
|
||
|
||
if len(configs) == 0 {
|
||
return nil, fmt.Errorf("all target servers have auth errors for cron job %s", job.Name)
|
||
}
|
||
|
||
// 并发执行
|
||
if timeoutSec <= 0 {
|
||
timeoutSec = 300
|
||
}
|
||
results := remoteexec.BatchExec(configs, content, time.Duration(timeoutSec)*time.Second, 5)
|
||
return results, nil
|
||
}
|