chore: 统一本地服务器与远程仓库源码
- 合并 main 与 master 历史并以服务器结构为基线 - 保留迁移、数据保留、AWS 账单与部署安全能力 - 建立 SDD 规格和 CI 验证闭环
This commit is contained in:
commit
fd60361d93
460 files changed
+37492
-23871
No files matched your search
@@ -6,11 +6,13 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/agentauth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -142,28 +144,70 @@ func runSSHCommandWithTimeout(client *ssh.Client, cmd string, timeout time.Durat
|
||||
}
|
||||
}
|
||||
|
||||
// installServerURL 根据请求构造后端可访问地址,兼容 Nginx 等反向代理 // Logan.Li
|
||||
func installServerURL(c *gin.Context) string {
|
||||
// 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 = 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 = fwd
|
||||
host = strings.TrimSpace(strings.Split(fwd, ",")[0])
|
||||
}
|
||||
// 去掉 scheme 的默认端口,避免生成 http://example.com:80 这类无效地址 // Logan.Li
|
||||
if scheme == "http" && strings.HasSuffix(host, ":80") {
|
||||
host = strings.TrimSuffix(host, ":80")
|
||||
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 scheme == "https" && strings.HasSuffix(host, ":443") {
|
||||
host = strings.TrimSuffix(host, ":443")
|
||||
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 将资产主机名显式传给 Agent,避免系统主机名与资产别名不一致。 // Logan.Li
|
||||
func agentInstallCommand(serverURL, token, hostname string) string {
|
||||
scriptURL := shellQuote(serverURL + "/agent/install.sh")
|
||||
serverArg := shellQuote(serverURL)
|
||||
tokenArg := shellQuote(token)
|
||||
hostnameArg := shellQuote(hostname)
|
||||
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 --hostname %s; else sudo -n bash "$tmp" --server %s --token %s --hostname %s; fi`,
|
||||
scriptURL, serverArg, tokenArg, hostnameArg, serverArg, tokenArg, hostnameArg,
|
||||
)
|
||||
}
|
||||
|
||||
// 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 安装命令执行失败"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
// detectAgentVersion SSH 到目标主机读取已安装 Agent 的真实版本号 // Logan.Li
|
||||
@@ -187,11 +231,12 @@ 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"`
|
||||
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)
|
||||
@@ -273,31 +318,24 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 3. 构造安装命令(使用 Agent API 服务地址)
|
||||
serverURL := installServerURL(c)
|
||||
token := req.Token
|
||||
if token == "" {
|
||||
token = c.Query("token")
|
||||
serverURL, urlErr := installServerURL(c, req.ServerURL)
|
||||
if urlErr != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: urlErr.Error()})
|
||||
return
|
||||
}
|
||||
token, err := agentauth.Issue(h.db, server.Hostname, ac.username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "签发 Agent 凭证失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 构造安装命令:root 用户直接 bash,非 root 用 sudo -n(无密码才执行,避免挂死)// Logan.Li
|
||||
installCmd := fmt.Sprintf(
|
||||
`if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`,
|
||||
serverURL, serverURL, token,
|
||||
serverURL, serverURL, token,
|
||||
)
|
||||
// 4. 先下载脚本再执行,curl/WAF 失败时不会被管道末端 bash 的退出码掩盖。 // Logan.Li
|
||||
installCmd := agentInstallCommand(serverURL, token, server.Hostname)
|
||||
|
||||
// 5. 执行安装
|
||||
installOut, err := runSSHCommand(client, installCmd)
|
||||
if err != nil {
|
||||
msg := "安装命令执行失败"
|
||||
lowerOut := strings.ToLower(installOut)
|
||||
if strings.Contains(lowerOut, "sudo: a password is required") || strings.Contains(lowerOut, "需要密码") {
|
||||
msg = "目标主机 SSH 用户没有免密 sudo 权限,请使用 root 账号或配置 passwordless sudo"
|
||||
} else if strings.Contains(lowerOut, "curl") && (strings.Contains(lowerOut, "not found") || strings.Contains(lowerOut, "未找到")) {
|
||||
msg = "目标主机未安装 curl"
|
||||
} else if strings.Contains(lowerOut, "下载失败") || strings.Contains(lowerOut, "failed to connect") || strings.Contains(lowerOut, "connection refused") {
|
||||
msg = "目标主机无法下载安装脚本,请检查后端地址 " + serverURL + " 是否可达"
|
||||
}
|
||||
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{
|
||||
@@ -311,6 +349,13 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
// 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 {
|
||||
@@ -339,7 +384,7 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Code: 200,
|
||||
Message: func() string {
|
||||
if installed {
|
||||
return "Agent 安装成功,已自动启动并设置开机自启"
|
||||
@@ -365,11 +410,7 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
|
||||
|
||||
// 不强制 server.Status==running:只要 SSH 可达即可安装(DB 状态可能滞后于实际开机状态) // Logan.Li
|
||||
|
||||
installCmd := fmt.Sprintf(
|
||||
`if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`,
|
||||
serverURL, serverURL, token,
|
||||
serverURL, serverURL, token,
|
||||
)
|
||||
installCmd := agentInstallCommand(serverURL, token, server.Hostname)
|
||||
|
||||
installOut, err := runSSHCommand(client, installCmd)
|
||||
if err != nil {
|
||||
@@ -378,6 +419,9 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
|
||||
|
||||
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
|
||||
@@ -400,6 +444,7 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
|
||||
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"`
|
||||
@@ -412,8 +457,11 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
serverURL := installServerURL(c)
|
||||
token := c.Query("token")
|
||||
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
|
||||
|
||||
// 异步执行:立即返回,后台 goroutine 逐台安装
|
||||
@@ -444,13 +492,23 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
token, tokenErr := agentauth.Issue(h.db, server.Hostname, ac.username)
|
||||
if tokenErr != nil {
|
||||
log.Printf("[BatchInstall] %s 签发 Agent 凭证失败: %v", server.Hostname, tokenErr)
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
log.Printf("[BatchInstall] %s (%s) 安装失败: %v", server.Hostname, server.IP, err)
|
||||
h.recordAgentAudit(ac, "install", res, rid, 0, reqDetail, installOut, truncateAuditText(err.Error(), 500))
|
||||
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)
|
||||
@@ -592,13 +650,18 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) {
|
||||
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 := installServerURL(c)
|
||||
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
|
||||
|
||||
// 异步执行:立即返回,后台逐台升级
|
||||
@@ -634,8 +697,8 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
|
||||
// 下载新二进制 + 获取版本号 + 重启服务
|
||||
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 version 2>/dev/null | awk '/AIOps Agent/{print $3}'); echo "UPGRADE_OK ver=$ver"`,
|
||||
serverURL, binaryFile,
|
||||
`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)
|
||||
|
||||
Reference in new issue
Block a user