fix: 使用访问域名安装 Agent 并发布下载文件

- 前端提交当前访问 origin,避免反向代理内网地址泄漏
- 校验并安全转义 Agent 服务地址
- 主程序构建时同步生成多平台 Agent 二进制
This commit is contained in:
Logan.Li committed 2026-07-25 15:00:02 +08:00
1 parent 0b785da8ae
commit 7553ef54de
5 files changed
+119 -50

No files matched your search

+53 -24
View File
@@ -6,6 +6,7 @@ import (
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -142,28 +143,41 @@ 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", scheme, host)
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host), nil
}
// shellQuote 对 SSH 远程命令参数做单引号转义。 // Logan.Li
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}
// detectAgentVersion SSH 到目标主机读取已安装 Agent 的真实版本号 // Logan.Li
@@ -187,11 +201,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,7 +288,11 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
}
// 3. 构造安装命令(使用 Agent API 服务地址)
serverURL := installServerURL(c)
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")
@@ -282,8 +301,8 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
// 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,
shellQuote(serverURL), shellQuote(serverURL), shellQuote(token),
shellQuote(serverURL), shellQuote(serverURL), shellQuote(token),
)
// 5. 执行安装
@@ -367,8 +386,8 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
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,
shellQuote(serverURL), shellQuote(serverURL), shellQuote(token),
shellQuote(serverURL), shellQuote(serverURL), shellQuote(token),
)
installOut, err := runSSHCommand(client, installCmd)
@@ -400,6 +419,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,7 +432,11 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
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
}
token := c.Query("token")
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
@@ -592,13 +616,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 +663,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)
+38
View File
@@ -0,0 +1,38 @@
package controller
import "testing"
// TestValidateInstallServerURL 验证 Agent 公网地址仅接受安全的 HTTP(S) origin。 // Logan.Li
func TestValidateInstallServerURL(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{name: "HTTP 域名", input: "http://aiops.example.com", want: "http://aiops.example.com"},
{name: "HTTPS 域名", input: "https://aiops.example.com/", want: "https://aiops.example.com"},
{name: "自定义端口", input: "https://aiops.example.com:8443", want: "https://aiops.example.com:8443"},
{name: "拒绝路径", input: "https://aiops.example.com/api", wantErr: true},
{name: "拒绝查询参数", input: "https://aiops.example.com?x=1", wantErr: true},
{name: "拒绝非 HTTP 协议", input: "file:///tmp/agent", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := validateInstallServerURL(test.input)
if test.wantErr {
if err == nil {
t.Fatalf("期望校验失败,实际返回 %q", got)
}
return
}
if err != nil {
t.Fatalf("地址校验失败: %v", err)
}
if got != test.want {
t.Fatalf("期望 %q,实际 %q", test.want, got)
}
})
}
}