chore: 统一本地服务器与远程仓库源码

- 合并 main 与 master 历史并以服务器结构为基线
- 保留迁移、数据保留、AWS 账单与部署安全能力
- 建立 SDD 规格和 CI 验证闭环
This commit is contained in:
Logan.Li committed 2026-08-05 20:03:25 +08:00
commit fd60361d93
460 files changed
+37492 -23871

No files matched your search

+110 -47
View File
@@ -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)